Skip to content

Commit

Permalink
feat: add sorting to table
Browse files Browse the repository at this point in the history
Co-authored-by: Michele Nuzzi <m.nuzzi@cittametropolitana.ba.it>
Co-authored-by: Antonino Bonanno <nino.bonanno96@gmail.com>
Co-authored-by: Andrea Stagi <stagi.andrea@gmail.com>
  • Loading branch information
4 people authored Jan 15, 2024
1 parent a7ed045 commit 0f039f4
Show file tree
Hide file tree
Showing 12 changed files with 576 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { ItErrorPageComponent } from './utils/error-page/error-page.component';
import { ItChipComponent } from './core/chip/chip.component';
import { ItForwardDirective } from './core/forward/forward.directive';
import { MarkMatchingTextPipe } from '../pipes/mark-matching-text.pipe';
import { ItSortDirective } from "./core/table/sort/sort.directive";
import { ItSortHeaderComponent } from "./core/table/sort/sort-header/sort-header.component";


/**
Expand All @@ -44,7 +46,7 @@ import { MarkMatchingTextPipe } from '../pipes/mark-matching-text.pipe';
const core = [
ItAccordionComponent,
ItAlertComponent,
ItAvatarGroupItemComponent,
ItAvatarGroupItemComponent,
ItAvatarGroupComponent,
ItAvatarDropdownComponent,
ItAvatarDropdownItemComponent,
Expand All @@ -71,6 +73,8 @@ const core = [
ItSteppersModule,
ItTabModule,
ItTableComponent,
ItSortDirective,
ItSortHeaderComponent,
ItTooltipDirective
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!--
We set the `tabindex` on an element inside the table header, rather than the header itself,
because of a bug in NVDA where having a `tabindex` on a `th` breaks keyboard navigation in the
table (see https://github.com/nvaccess/nvda/issues/7718). This allows for the header to both
be focusable, and have screen readers read out its `aria-sort` state. We prefer this approach
over having a button with an `aria-label` inside the header, because the button's `aria-label`
will be read out as the user is navigating the table's cell (see #13012).
The approach is based off of: https://dequeuniversity.com/library/aria/tables/sf-sortable-grid
-->
<div class="it-sort-header-container it-focus-indicator"
[class.it-sort-header-sorted]="isSorted"
[class.it-sort-header-position-before]="arrowPosition === 'before'"
[attr.tabindex]="isDisabled ? null : 0"
[attr.role]="isDisabled ? null : 'button'">

<!--
We have to keep it due to a large number of screenshot diff failures. It should be removed eventually.
Note that the difference isn't visible with a shorter header, but once it breaks up into multiple lines, this element
causes it to be center-aligned, whereas removing it will keep the text to the left.
-->
<div class="it-sort-header-content">
<ng-content></ng-content>
</div>

<it-icon class="it-sort-arrow" size="sm" [name]="arrowIconClass" />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.it-sort-header-container {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
letter-spacing: normal;

// Needs to be reset since we don't want an outline around the inner
// div which is focusable. We have our own alternate focus styling.
outline: 0;

.it-sort-header-disabled & {
cursor: default;

.it-sort-arrow {
opacity: 0 !important;
}
}

// For the sort-header element, default inset/offset values are necessary to ensure that
// the focus indicator is sufficiently contrastive and renders appropriately.
&::before {
$border-width: 3px;
$offset: calc(#{$border-width} + 2px);
margin: calc(#{$offset} * -1);
}

&.it-sort-header-position-before {
flex-direction: row-reverse;
justify-content: left;
gap: 0.5rem;
}

.it-sort-arrow {
opacity: 0;
transition: opacity .3s ease-out;
-moz-transition: opacity .3s ease-out;
-webkit-transition: opacity .3s ease-out;
-o-transition: opacity .3s ease-out;
}

&:hover {
.it-sort-arrow {
opacity: 0.5;
}
}

&.it-sort-header-sorted {
.it-sort-arrow {
opacity: 1 !important;
}
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ItSortHeaderComponent } from './sort-header.component';
import { ItSortDirective } from '../sort.directive';
import {tb_base} from "../../../../../../test";

describe('ItSortHeaderComponent', () => {
let component: ItSortHeaderComponent;
let fixture: ComponentFixture<ItSortHeaderComponent>;
let sortDirective: ItSortDirective = new ItSortDirective();

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
ItSortHeaderComponent,
],
providers: [
{ provide: ItSortDirective, useValue: sortDirective },
...tb_base.providers
],
});
fixture = TestBed.createComponent(ItSortHeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
booleanAttribute,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostBinding,
HostListener,
Inject,
Input,
OnDestroy,
OnInit,
Optional,
ViewEncapsulation
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ItSortDirective,} from '../sort.directive';
import {merge, Subscription} from 'rxjs';
import {ItIconComponent} from '../../../../utils/icon/icon.component';
import {IconName} from "../../../../../interfaces/icon";
import {
IT_SORT_DEFAULT_OPTIONS,
ItSortable,
ItSortDefaultOptions,
SortDirection,
SortHeaderArrowPosition
} from "../../../../../interfaces/sortable-table";


/**
* Applies sorting behavior (click to change sort) and styles to an element, including an
* arrow to display the current sort direction.
*
* Must be provided with an id and contained within a parent ItSort directive.
*
* If used on header cells in a CdkTable, it will automatically default its id from its containing
* column definition.
*/
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: '[it-sort-header]',
exportAs: 'itSortHeader',
standalone: true,
imports: [CommonModule, ItIconComponent],
templateUrl: './sort-header.component.html',
styleUrls: ['./sort-header.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ItSortHeaderComponent implements ItSortable, OnDestroy, OnInit {
/**
* ID of this sort header. If used within the context of a CdkColumnDef, this will default to
* the column's name.
*/
@Input('it-sort-header') id!: string;

/** Sets the position of the arrow that displays when sorted. */
@Input() arrowPosition: SortHeaderArrowPosition = 'after';

/** Overrides the sort start value of the containing MatSort for this SortHeaderComponent. */
@Input() start?: SortDirection;

/** whether the sort header is disabled. */
@Input({transform: booleanAttribute})
sortDisabled: boolean = false;

/** Overrides the disable clear value of the containing SortDirective for this MatSortable. */
@Input({transform: booleanAttribute})
disableSortClear?: boolean;

@HostBinding('class')
public readonly sortHeaderClass = 'it-sort-header';

private _rerenderSubscription?: Subscription;

/** The direction the arrow should be facing according to the current state. */
private _arrowDirection?: SortDirection;

constructor(
private readonly _changeDetectorRef: ChangeDetectorRef,
// `SortDirective` is not optionally injected, but just asserted manually w/ better error.
@Optional() public readonly _sort: ItSortDirective,
@Optional() @Inject(IT_SORT_DEFAULT_OPTIONS) defaultOptions?: ItSortDefaultOptions,
) {
if (defaultOptions?.arrowPosition) {
this.arrowPosition = defaultOptions?.arrowPosition;
}

this._handleStateChanges();
}

ngOnInit() {
// Initialize the direction of the arrow and set the view state to be immediately that state.
this.updateArrowDirection();
this._sort.register(this);
}

ngOnDestroy() {
this._sort.deregister(this);
this._rerenderSubscription?.unsubscribe();
}

@HostListener('click')
_handleClick() {
if (!this.isDisabled) {
this._sort.sort(this);
}
}

/**
* Whether this MatSortHeader is currently sorted in either ascending or descending order.
*/
protected get isSorted() {
return (
this._sort.active == this.id &&
(this._sort.direction === 'asc' || this._sort.direction === 'desc')
);
}

/**
* Returns the icon class by the arrow direction
*/
protected get arrowIconClass(): IconName {
return `${this._arrowDirection == 'asc' ? 'arrow-up' : 'arrow-down'}`;
}

/**
* Updates the direction the arrow should be pointing. If it is not sorted, the arrow should be
* facing the start direction. Otherwise if it is sorted, the arrow should point in the currently
* active sorted direction. The reason this is updated through a function is because the direction
* should only be changed at specific times - when deactivated but the hint is displayed and when
* the sort is active and the direction changes. Otherwise the arrow's direction should linger
* in cases such as the sort becoming deactivated but we want to animate the arrow away while
* preserving its direction, even though the next sort direction is actually different and should
* only be changed once the arrow displays again (hint or activation).
*/
private updateArrowDirection() {
this._arrowDirection = this.isSorted ? this._sort.direction : this.start || this._sort.start;
}

@HostBinding('class.it-sort-header-disabled')
public get isDisabled() {
return this._sort.sortDisabled || this.sortDisabled;
}

/**
* Gets the aria-sort attribute that should be applied to this sort header. If this header
* is not sorted, returns null so that the attribute is removed from the host element. Aria spec
* says that the aria-sort property should only be present on one header at a time, so removing
* ensures this is true.
*/
@HostBinding('attr.aria-sort')
public get ariaSortAttribute() {
if (!this.isSorted) {
return 'none';
}

return this._sort.direction == 'asc' ? 'ascending' : 'descending';
}


/** Handles changes in the sorting state. */
private _handleStateChanges() {
this._rerenderSubscription = merge(
this._sort.sortChange,
this._sort._stateChanges,
).subscribe(() => {
if (this.isSorted) {
this.updateArrowDirection();
}
this._changeDetectorRef.markForCheck();
});
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ItSortDirective} from './sort.directive';
import {tb_base} from 'projects/design-angular-kit/src/test';
import {TranslateModule} from '@ngx-translate/core';
import {ChangeDetectionStrategy, Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ItSortHeaderComponent} from './sort-header/sort-header.component';
import {ItSortEvent} from "../../../../interfaces/sortable-table";

@Component({
standalone: true,
template: `
<it-table itSort (sortChange)="sortData($event)">
<ng-container thead>
<tr>
<th it-sort-header="name" scope="col">Nome</th>
</tr>
</ng-container>
<ng-container tbody>
<tr>
<td>Mario</td>
</tr>
<tr>
<td>Alessandro</td>
</tr>
<tr>
<td>Francesco</td>
</tr>
</ng-container>
</it-table>`,
imports: [ItSortDirective, ItSortHeaderComponent],
})
class TestComponent {
sortData(event: ItSortEvent) {
}
}

describe('ItSortDirective', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let des: DebugElement[];

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ItSortDirective, ItSortHeaderComponent, TestComponent, TranslateModule.forRoot()],
providers: tb_base.providers
})
.overrideComponent(TestComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
})
.compileComponents();

fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();

// all elements with an attached SortDirective
des = fixture.debugElement.queryAll(By.directive(ItSortHeaderComponent));

});

it('should create an instance', () => {
const directive = new ItSortDirective();
expect(directive).toBeTruthy();
});

it('should emit sort event', () => {
const th = des[0].nativeElement as HTMLTableColElement;
spyOn(component, "sortData");
th.dispatchEvent(new Event('click'));
fixture.detectChanges();
expect(component.sortData).toHaveBeenCalledWith({active: "name", direction: "asc"});
});
});
Loading

1 comment on commit 0f039f4

@vercel
Copy link

@vercel vercel bot commented on 0f039f4 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.