diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index dff506719..e7dae74f1 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -85,6 +85,7 @@ 3306 3370 v2 header icons + 3344 Table Multi-Sort 3396 Text heading-2xs size 3407 Skip Focus on Tab 3407 Tabs Orientation diff --git a/apps/prs/angular/src/app/app.routes.ts b/apps/prs/angular/src/app/app.routes.ts index 7c300d9ee..b96375774 100644 --- a/apps/prs/angular/src/app/app.routes.ts +++ b/apps/prs/angular/src/app/app.routes.ts @@ -64,6 +64,7 @@ import { Feat2722Component } from "../routes/features/feat2722/feat2722.componen import { Feat2730Component } from "../routes/features/feat2730/feat2730.component"; import { Feat2829Component } from "../routes/features/feat2829/feat2829.component"; import { Feat3102Component } from "../routes/features/feat3102/feat3102.component"; +import { Feat3344Component } from "../routes/features/feat3344/feat3344.component"; import { Feat3137Component } from "../routes/features/feat3137/feat3137.component"; import { Feat3241Component } from "../routes/features/feat3241/feat3241.component"; import { Feat3229Component } from "../routes/features/feat3229/feat3229.component"; @@ -142,6 +143,7 @@ export const appRoutes: Route[] = [ { path: "features/3137", component: Feat3137Component }, { path: "features/3241", component: Feat3241Component }, { path: "features/v2-icons", component: FeatV2IconsComponent }, + { path: "features/3344", component: Feat3344Component }, { path: "features/3137", component: Feat3137Component }, { path: "features/3229", component: Feat3229Component }, { path: "features/3306", component: Feat3306Component }, diff --git a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html new file mode 100644 index 000000000..0eda15483 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html @@ -0,0 +1,111 @@ +

PR 4: Table & TableSortHeader V2

+ + + +

+ TableSortHeader:
+ - Always-visible sort icon (chevron-expand when unsorted)
+ - Arrow-up/arrow-down for sorted states
+ - sortOrder prop shows "1", "2" for multi-column sort
+
+ Table:
+ - sortMode="single" (default) or "multi" (up to 2 columns)
+ - Initial sort declared on headers via direction + sortOrder
+ - onSort callback with sortBy, sortDir, and sorts array +

+
+
+ + + +

Test 1: Single-Column Sort (Default)

+

Click column headers to sort. Only one column sorts at a time (default behavior).

+

Current sort: {{ formatSorts(currentSorts) }}

+ + + + + + Name + + + Department + + + Salary + + + + + @for (row of singleSorted; track row.id) { + + {{ row.name }} + {{ row.department }} + {{ formatCurrency(row.salary) }} + + } + + + + + +

Test 2: Multi-Column Sort

+

With sortMode="multi", click columns to add them to sort order (up to 2).

+

Current sort: {{ formatSorts(multiSorts) }}

+ + + + + + Name + + + Department + + + Salary + + + + + @for (row of multiSorted; track row.id) { + + {{ row.name }} + {{ row.department }} + {{ formatCurrency(row.salary) }} + + } + + + + + +

Test 3: Multi Initial Sort (declarative)

+

Two initial sorts declared on headers: Department ascending (primary), Salary descending (secondary). +Use direction + sortOrder on each header to set priority.

+

Current sort: {{ formatSorts(test3Sorts) }}

+ + + + + + Name + + + Department + + + Salary + + + + + @for (row of test3Sorted; track row.id) { + + {{ row.name }} + {{ row.department }} + {{ formatCurrency(row.salary) }} + + } + + diff --git a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts new file mode 100644 index 000000000..722af099e --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts @@ -0,0 +1,100 @@ +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { + GoabBlock, + GoabDivider, + GoabDetails, + GoabxTable, + GoabxTableSortHeader, +} from "@abgov/angular-components"; +import { GoabTableOnSortDetail, GoabTableSortEntry } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "abgov-feat3344", + templateUrl: "./feat3344.component.html", + imports: [ + GoabBlock, + GoabDivider, + GoabDetails, + GoabxTable, + GoabxTableSortHeader, + ], +}) +export class Feat3344Component implements OnInit, OnDestroy { + private v2TokensLink: HTMLLinkElement | null = null; + + currentSorts: GoabTableSortEntry[] = []; + multiSorts: GoabTableSortEntry[] = []; + test3Sorts: GoabTableSortEntry[] = []; + + data = [ + { id: 1, name: "Alice Johnson", department: "Engineering", salary: 95000 }, + { id: 2, name: "Bob Smith", department: "Marketing", salary: 72000 }, + { id: 3, name: "Carol Williams", department: "Engineering", salary: 105000 }, + { id: 4, name: "David Brown", department: "Sales", salary: 68000 }, + { id: 5, name: "Eve Davis", department: "Marketing", salary: 78000 }, + ]; + + singleSorted = [...this.data]; + multiSorted = [...this.data]; + test3Sorted = [...this.data]; + + ngOnInit() { + this.v2TokensLink = document.createElement("link"); + this.v2TokensLink.rel = "stylesheet"; + this.v2TokensLink.href = "/v2-tokens/tokens.css"; + document.head.appendChild(this.v2TokensLink); + } + + ngOnDestroy() { + if (this.v2TokensLink) { + document.head.removeChild(this.v2TokensLink); + this.v2TokensLink = null; + } + } + + onSingleSortChange(detail: GoabTableOnSortDetail) { + this.currentSorts = detail.sorts ?? []; + this.singleSorted = this.sortData(this.data, this.currentSorts); + } + + onMultiSortChange(detail: GoabTableOnSortDetail) { + this.multiSorts = detail.sorts ?? []; + this.multiSorted = this.sortData(this.data, this.multiSorts); + } + + onTest3SortChange(detail: GoabTableOnSortDetail) { + this.test3Sorts = detail.sorts ?? []; + this.test3Sorted = this.sortData(this.data, this.test3Sorts); + } + + formatSorts(sorts: GoabTableSortEntry[]): string { + if (sorts.length === 0) return "None"; + return sorts.map((s, i) => `${i + 1}. ${s.column} (${s.direction})`).join(", "); + } + + formatCurrency(value: number): string { + return "$" + value.toLocaleString(); + } + + private sortData( + data: typeof this.data, + sorts: GoabTableSortEntry[], + ): typeof this.data { + if (sorts.length === 0) return [...data]; + return [...data].sort((a, b) => { + for (const { column, direction } of sorts) { + const aVal = a[column as keyof typeof a]; + const bVal = b[column as keyof typeof b]; + let cmp = 0; + if (typeof aVal === "string" && typeof bVal === "string") { + cmp = aVal.localeCompare(bVal); + } else { + cmp = (aVal as number) - (bVal as number); + } + if (cmp !== 0) return direction === "asc" ? cmp : -cmp; + } + return 0; + }); + } +} diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 9ea799e24..6d8e441f8 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -92,12 +92,13 @@ export function App() { 3137 Work Side Menu Group 3241 V2 Experimental Wrappers v2 header icons - 3407 Skip Focus on Tab - 3407 Tabs Orientation 3229 V2 Menu Button vs size and icon-only + 3344 Table Multi-Sort 3306 Custom slug value for tabs 3370 Clear calendar day selection 3396 Text heading-2xs size + 3407 Skip Focus on Tab + 3407 Tabs Orientation A diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 4cf795a65..3bd4a5f75 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -72,6 +72,7 @@ import { Feat3241Route } from "./routes/features/feat3241"; import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; import { Feat3407SkipOnFocusTabRoute } from "./routes/features/feat3407SkipOnFocusTab"; import { Feat3407StackOnMobileRoute } from "./routes/features/feat3407StackOnMobile"; +import { Feat3344Route } from "./routes/features/feat3344"; import { Feat3137Route } from "./routes/features/feat3137"; import { Feat3306Route } from "./routes/features/feat3306"; import { Feat2469Route } from "./routes/features/feat2469"; @@ -158,11 +159,18 @@ root.render( } /> } /> } /> + } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> diff --git a/apps/prs/react/src/routes/features/feat3344.tsx b/apps/prs/react/src/routes/features/feat3344.tsx new file mode 100644 index 000000000..354e8adc9 --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3344.tsx @@ -0,0 +1,200 @@ +/** + * PR 4: Table & TableSortHeader V2 Updates + * + * Tests: + * - TableSortHeader with chevron-expand icon (unsorted state) + * - TableSortHeader with arrow-up/arrow-down (sorted states) + * - Built-in single and multi-column sorting + * - sortOrder numbers for multi-column sort priority + * - V2 focus ring on icon only (not whole button) + */ + +import { useEffect, useMemo, useState } from "react"; +import { + GoabBlock, + GoabDivider, + GoabDetails, +} from "@abgov/react-components"; +// ?url suffix tells Vite to resolve the path without injecting the CSS +import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; +import { GoabxTable, GoabxTableSortHeader } from "@abgov/react-components/experimental"; +import type { GoabTableSortEntry } from "@abgov/ui-components-common"; + +type RowData = { id: number; name: string; department: string; salary: number }; + +const initialData: RowData[] = [ + { id: 1, name: "Alice Johnson", department: "Engineering", salary: 95000 }, + { id: 2, name: "Bob Smith", department: "Marketing", salary: 72000 }, + { id: 3, name: "Carol Williams", department: "Engineering", salary: 105000 }, + { id: 4, name: "David Brown", department: "Sales", salary: 68000 }, + { id: 5, name: "Eve Davis", department: "Marketing", salary: 78000 }, +]; + +function sortData(data: RowData[], sorts: GoabTableSortEntry[]): RowData[] { + if (sorts.length === 0) return data; + return [...data].sort((a, b) => { + for (const { column, direction } of sorts) { + const aVal = a[column as keyof RowData]; + const bVal = b[column as keyof RowData]; + let cmp = 0; + if (typeof aVal === "string" && typeof bVal === "string") { + cmp = aVal.localeCompare(bVal); + } else { + cmp = (aVal as number) - (bVal as number); + } + if (cmp !== 0) return direction === "asc" ? cmp : -cmp; + } + return 0; + }); +} + +export function Feat3344Route() { + useEffect(() => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = v2TokensUrl; + document.head.appendChild(link); + return () => { + document.head.removeChild(link); + }; + }, []); + + const [currentSorts, setCurrentSorts] = useState([]); + const [multiSorts, setMultiSorts] = useState([]); + const [test3Sorts, setTest3Sorts] = useState([]); + + const singleSorted = useMemo(() => sortData(initialData, currentSorts), [currentSorts]); + const multiSorted = useMemo(() => sortData(initialData, multiSorts), [multiSorts]); + const test3Sorted = useMemo(() => sortData(initialData, test3Sorts), [test3Sorts]); + + const formatSorts = (sorts: GoabTableSortEntry[]): string => { + if (sorts.length === 0) return "None"; + return sorts.map((s, i) => `${i + 1}. ${s.column} (${s.direction})`).join(", "); + }; + + return ( +
+

PR 4: Table & TableSortHeader V2

+ + + +

+ TableSortHeader:
+ - Always-visible sort icon (chevron-expand when unsorted)
+ - Arrow-up/arrow-down for sorted states
+ - sortOrder prop shows "1", "2" for multi-column sort
+
+ Table:
+ - sortMode="single" (default) or "multi" (up to 2 columns)
+ - Initial sort declared on headers via direction + sortOrder
+ - onSort callback with sortBy, sortDir, and sorts array +

+
+
+ + + +

Test 1: Single-Column Sort (Default)

+

Click column headers to sort. Only one column sorts at a time (default behavior).

+

Current sort: {formatSorts(currentSorts)}

+ + setCurrentSorts(detail.sorts ?? [])}> + + + + Name + + + Department + + + Salary + + + + + {singleSorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + + + +

Test 2: Multi-Column Sort

+

With sortMode="multi", click columns to add them to sort order (up to 2).

+

Current sort: {formatSorts(multiSorts)}

+ + setMultiSorts(detail.sorts ?? [])}> + + + + Name + + + Department + + + Salary + + + + + {multiSorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + + + +

Test 3: Multi Initial Sort (declarative)

+

Two initial sorts declared on headers: Department ascending (primary), Salary + descending (secondary). Use direction + sortOrder on each header to set priority.

+

Current sort: {formatSorts(test3Sorts)}

+ + setTest3Sorts(detail.sorts ?? [])} + > + + + + Name + + + + Department + + + + + Salary + + + + + + {test3Sorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + +
+ ); +} + +export default Feat3344Route; diff --git a/apps/prs/react/src/styles.css b/apps/prs/react/src/styles.css index 90d4ee007..2450e3000 100644 --- a/apps/prs/react/src/styles.css +++ b/apps/prs/react/src/styles.css @@ -1 +1,8 @@ /* You can add global styles to this file, and also import other style files */ + +/* + * V2 Design Tokens + * Uncomment the line below to test V2 component styling. + * V2 tokens override V1 CSS custom properties with updated values. + */ +/* @import "@abgov/design-tokens-v2/dist/tokens.css"; */ diff --git a/docs/generated/component-apis/table-sort-header.json b/docs/generated/component-apis/table-sort-header.json new file mode 100644 index 000000000..dce036035 --- /dev/null +++ b/docs/generated/component-apis/table-sort-header.json @@ -0,0 +1,41 @@ +{ + "componentSlug": "table-sort-header", + "extractedFrom": "libs/web-components/src/components/table/TableSortHeader.svelte", + "extractedAt": "2026-02-26T00:00:00.000Z", + "props": [ + { + "name": "name", + "type": "string", + "required": false, + "default": "", + "description": "Column name identifier for sorting." + }, + { + "name": "direction", + "type": "\"asc\" | \"desc\" | \"none\"", + "typeLabel": "GoabTableSortDirection", + "values": [ + "asc", + "desc", + "none" + ], + "required": false, + "default": "none", + "description": "Sets the sort direction indicator." + }, + { + "name": "sortOrder", + "type": "number", + "required": false, + "default": 0, + "description": "Sort order number for multi-column sort display (1, 2, etc)." + } + ], + "events": [], + "slots": [ + { + "name": "default", + "description": "" + } + ] +} diff --git a/docs/generated/component-apis/table.json b/docs/generated/component-apis/table.json index 15e3104de..3a17eb9c3 100644 --- a/docs/generated/component-apis/table.json +++ b/docs/generated/component-apis/table.json @@ -36,6 +36,18 @@ "default": "normal", "description": "A relaxed variant of the table with more vertical padding for the cells." }, + { + "name": "sortMode", + "type": "\"single\" | \"multi\"", + "typeLabel": "GoabTableSortMode", + "values": [ + "single", + "multi" + ], + "required": false, + "default": "single", + "description": "Sort mode: \"single\" allows one column, \"multi\" allows up to 2 columns." + }, { "name": "testId", "type": "string", diff --git a/docs/src/data/configurations/index.ts b/docs/src/data/configurations/index.ts index 37cb8943a..7376dea4d 100644 --- a/docs/src/data/configurations/index.ts +++ b/docs/src/data/configurations/index.ts @@ -73,6 +73,7 @@ export { cardContentConfigurations } from './card-content'; export { cardImageConfigurations } from './card-image'; export { cardActionsConfigurations } from './card-actions'; export { tableConfigurations } from './table'; +export { tableSortHeaderConfigurations } from './table-sort-header'; export { dataGridConfigurations } from './data-grid'; export { modalConfigurations } from './modal'; export { drawerConfigurations } from './drawer'; @@ -147,6 +148,7 @@ import { cardContentConfigurations } from './card-content'; import { cardImageConfigurations } from './card-image'; import { cardActionsConfigurations } from './card-actions'; import { tableConfigurations } from './table'; +import { tableSortHeaderConfigurations } from './table-sort-header'; import { dataGridConfigurations } from './data-grid'; import { modalConfigurations } from './modal'; import { drawerConfigurations } from './drawer'; @@ -231,6 +233,7 @@ export const configurationRegistry: ConfigurationRegistry = { 'card-image': cardImageConfigurations, 'card-actions': cardActionsConfigurations, table: tableConfigurations, + 'table-sort-header': tableSortHeaderConfigurations, 'data-grid': dataGridConfigurations, modal: modalConfigurations, drawer: drawerConfigurations, diff --git a/docs/src/data/configurations/table-sort-header.ts b/docs/src/data/configurations/table-sort-header.ts new file mode 100644 index 000000000..f3b7c2897 --- /dev/null +++ b/docs/src/data/configurations/table-sort-header.ts @@ -0,0 +1,48 @@ +/** + * TableSortHeader Component Configurations + * + * Sort headers for table columns, used within Table's thead. + */ + +import type { ComponentConfigurations } from './types'; + +export const tableSortHeaderConfigurations: ComponentConfigurations = { + componentSlug: 'table-sort-header', + componentName: 'TableSortHeader', + defaultConfigurationId: 'basic', + + configurations: [ + { + id: 'basic', + name: 'Basic sort header', + description: 'Sortable column header with name and direction', + code: { + react: ` + Name +`, + angular: ` + Name +`, + webComponents: ` + Name +`, + }, + }, + { + id: 'sort-order', + name: 'With sort order', + description: 'Sort header with sortOrder for multi-column sort priority display', + code: { + react: ` + Name +`, + angular: ` + Name +`, + webComponents: ` + Name +`, + }, + }, + ], +}; diff --git a/docs/src/data/configurations/table.ts b/docs/src/data/configurations/table.ts index a1f7fa7fb..3d3c93ca5 100644 --- a/docs/src/data/configurations/table.ts +++ b/docs/src/data/configurations/table.ts @@ -399,6 +399,174 @@ export const tableConfigurations: ComponentConfigurations = { +`, + }, + }, + { + id: 'single-sort', + name: 'Single-column sorting', + description: 'Sortable columns using TableSortHeader (default single sort mode)', + code: { + react: ` console.log(detail)}> + + + + Name + + + Status + + + Date + + + + + + John Smith + Active + Jan 15, 2024 + + + Jane Doe + Pending + Jan 16, 2024 + + +`, + angular: ` + + + + Name + + + Status + + + Date + + + + + + John Smith + Active + Jan 15, 2024 + + + Jane Doe + Pending + Jan 16, 2024 + + +`, + webComponents: ` + + + + + + + + + + + + + + + + + + + + +
NameStatusDate
John SmithActiveJan 15, 2024
Jane DoePendingJan 16, 2024
+
`, + }, + }, + { + id: 'multi-sort', + name: 'Multi-column sorting', + description: 'Sort by multiple columns with sortMode="multi" and sortOrder for priority', + code: { + react: ` console.log(detail)}> + + + + Name + + + Status + + + Date + + + + + + John Smith + Active + Jan 15, 2024 + + + Jane Doe + Pending + Jan 16, 2024 + + +`, + angular: ` + + + + Name + + + Status + + + Date + + + + + + John Smith + Active + Jan 15, 2024 + + + Jane Doe + Pending + Jan 16, 2024 + + +`, + webComponents: ` + + + + + + + + + + + + + + + + + + + + +
NameStatusDate
John SmithActiveJan 15, 2024
Jane DoePendingJan 16, 2024
`, }, }, diff --git a/libs/angular-components/src/experimental/table-sort-header/table-sort-header.spec.ts b/libs/angular-components/src/experimental/table-sort-header/table-sort-header.spec.ts index 91463dee5..8c82df241 100644 --- a/libs/angular-components/src/experimental/table-sort-header/table-sort-header.spec.ts +++ b/libs/angular-components/src/experimental/table-sort-header/table-sort-header.spec.ts @@ -17,6 +17,21 @@ class TestTableSortHeaderComponent { /** do nothing **/ } +@Component({ + standalone: true, + imports: [GoabxTableSortHeader], + template: ` + + + Salary + + + `, +}) +class TestTableSortHeaderWithSortOrderComponent { + /** do nothing **/ +} + describe("GoABTableSortHeader", () => { let fixture: ComponentFixture; @@ -39,3 +54,26 @@ describe("GoABTableSortHeader", () => { expect(el?.textContent).toContain("First name and really long header"); }); }); + +describe("GoABTableSortHeader with sortOrder", () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxTableSortHeader, TestTableSortHeaderWithSortOrderComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestTableSortHeaderWithSortOrderComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render sort-order attribute", () => { + const el = fixture.nativeElement.querySelector("goa-table-sort-header"); + expect(el?.getAttribute("sort-order")).toBe("2"); + expect(el?.getAttribute("direction")).toBe("desc"); + expect(el?.getAttribute("name")).toBe("salary"); + }); +}); diff --git a/libs/angular-components/src/experimental/table-sort-header/table-sort-header.ts b/libs/angular-components/src/experimental/table-sort-header/table-sort-header.ts index 70b4a83a3..5b8ba41cf 100644 --- a/libs/angular-components/src/experimental/table-sort-header/table-sort-header.ts +++ b/libs/angular-components/src/experimental/table-sort-header/table-sort-header.ts @@ -1,4 +1,4 @@ -import { GoabTableSortDirection } from "@abgov/ui-components-common"; +import { GoabTableSortDirection, GoabTableSortOrder } from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -16,6 +16,7 @@ import { CommonModule } from "@angular/common"; *ngIf="isReady" [attr.name]="name" [attr.direction]="direction" + [attr.sort-order]="sortOrder" > @@ -27,6 +28,7 @@ export class GoabxTableSortHeader implements OnInit { isReady = false; @Input() name?: string; @Input() direction?: GoabTableSortDirection = "none"; + @Input() sortOrder?: GoabTableSortOrder; constructor(private cdr: ChangeDetectorRef) {} diff --git a/libs/angular-components/src/experimental/table/table.spec.ts b/libs/angular-components/src/experimental/table/table.spec.ts index 11ce0650b..224fd0667 100644 --- a/libs/angular-components/src/experimental/table/table.spec.ts +++ b/libs/angular-components/src/experimental/table/table.spec.ts @@ -3,6 +3,7 @@ import { GoabxTable } from "./table"; import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { GoabTableOnSortDetail, + GoabTableSortMode, GoabTableVariant, Spacing, } from "@abgov/ui-components-common"; @@ -15,6 +16,8 @@ import { fireEvent } from "@testing-library/dom"; { component.width = "200px"; component.variant = "relaxed"; + component.sortMode = "multi"; + component.striped = true; component.testId = "foo"; component.mt = "s" as Spacing; component.mb = "xl" as Spacing; @@ -77,8 +84,11 @@ describe("GoabxTable", () => { it("should render", () => { const el = fixture.nativeElement.querySelector("goa-table"); + expect(el?.getAttribute("version")).toBe("2"); expect(el?.getAttribute("width")).toBe(component.width); expect(el?.getAttribute("variant")).toBe(component.variant); + expect(el?.getAttribute("sort-mode")).toBe(component.sortMode); + expect(el?.getAttribute("striped")).toBe("true"); expect(el?.getAttribute("testid")).toBe(component.testId); expect(el?.getAttribute("mt")).toBe(component.mt); expect(el?.getAttribute("mb")).toBe(component.mb); @@ -95,13 +105,16 @@ describe("GoabxTable", () => { it("should dispatch _sort", () => { const onSort = jest.spyOn(component, "onSort"); const el = fixture.nativeElement.querySelector("goa-table"); + const detail = { + sortBy: "column1", + sortDir: 1, + sorts: [{ column: "column1", direction: "asc" }], + }; fireEvent( el, - new CustomEvent("_sort", { - detail: { sortBy: "column1", sortDir: 1 }, - }), + new CustomEvent("_sort", { detail }), ); - expect(onSort).toHaveBeenCalledWith({ sortBy: "column1", sortDir: 1 }); + expect(onSort).toHaveBeenCalledWith(detail); }); }); diff --git a/libs/angular-components/src/experimental/table/table.ts b/libs/angular-components/src/experimental/table/table.ts index e57e05cb1..be621400c 100644 --- a/libs/angular-components/src/experimental/table/table.ts +++ b/libs/angular-components/src/experimental/table/table.ts @@ -1,4 +1,8 @@ -import { GoabTableOnSortDetail, GoabTableVariant } from "@abgov/ui-components-common"; +import { + GoabTableOnSortDetail, + GoabTableSortMode, + GoabTableVariant, +} from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -21,6 +25,7 @@ import { GoabBaseComponent } from "../base.component"; [attr.version]="version" [attr.width]="width" [attr.variant]="variant" + [attr.sort-mode]="sortMode" [attr.striped]="striped" [attr.testid]="testId" [attr.mt]="mt" @@ -42,6 +47,7 @@ export class GoabxTable extends GoabBaseComponent implements OnInit { version = "2"; @Input() width?: string; @Input() variant?: GoabTableVariant; + @Input() sortMode?: GoabTableSortMode; @Input({ transform: booleanAttribute }) striped?: boolean; constructor(private cdr: ChangeDetectorRef) { diff --git a/libs/angular-components/src/lib/components/table-sort-header/table-sort-header.ts b/libs/angular-components/src/lib/components/table-sort-header/table-sort-header.ts index 3d59b5347..bf2ccbd36 100644 --- a/libs/angular-components/src/lib/components/table-sort-header/table-sort-header.ts +++ b/libs/angular-components/src/lib/components/table-sort-header/table-sort-header.ts @@ -21,7 +21,6 @@ export class GoabTableSortHeader implements OnInit { isReady = false; @Input() name?: string; @Input() direction?: GoabTableSortDirection = "none"; - constructor(private cdr: ChangeDetectorRef) {} ngOnInit(): void { diff --git a/libs/common/src/lib/common.ts b/libs/common/src/lib/common.ts index 929677f21..3511922c3 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -242,9 +242,18 @@ export interface GoabTableProps extends Margins { testId?: string; } +export type GoabTableSortMode = "single" | "multi"; +export type GoabTableSortOrder = 1 | 2; + +export type GoabTableSortEntry = { + column: string; + direction: "asc" | "desc"; +}; + export type GoabTableOnSortDetail = { sortBy: string; sortDir: number; + sorts?: GoabTableSortEntry[]; }; // Spacer diff --git a/libs/react-components/specs/table.browser.spec.tsx b/libs/react-components/specs/table.browser.spec.tsx new file mode 100644 index 000000000..29b86aaaf --- /dev/null +++ b/libs/react-components/specs/table.browser.spec.tsx @@ -0,0 +1,531 @@ +import { render } from "vitest-browser-react"; +import { vi, describe, it, expect } from "vitest"; +import { useState, useMemo } from "react"; +import { GoabxTable, GoabxTableSortHeader } from "../src/experimental"; + +type SortEntry = { column: string; direction: "asc" | "desc" }; +type RowData = { id: number; name: string; department: string; salary: number }; + +const initialData: RowData[] = [ + { id: 1, name: "Alice Johnson", department: "Engineering", salary: 95000 }, + { id: 2, name: "Bob Smith", department: "Marketing", salary: 72000 }, + { id: 3, name: "Carol Williams", department: "Engineering", salary: 105000 }, + { id: 4, name: "David Brown", department: "Sales", salary: 68000 }, + { id: 5, name: "Eve Davis", department: "Marketing", salary: 78000 }, +]; + +function sortData(data: RowData[], sorts: SortEntry[]): RowData[] { + if (sorts.length === 0) return data; + return [...data].sort((a, b) => { + for (const { column, direction } of sorts) { + const aVal = a[column as keyof RowData]; + const bVal = b[column as keyof RowData]; + let cmp = 0; + if (typeof aVal === "string" && typeof bVal === "string") { + cmp = aVal.localeCompare(bVal); + } else { + cmp = (aVal as number) - (bVal as number); + } + if (cmp !== 0) return direction === "asc" ? cmp : -cmp; + } + return 0; + }); +} + +/** Query row names from the light DOM render container (not shadow DOM). */ +function getRowNames(container: HTMLElement): string[] { + const rows = container.querySelectorAll("tbody tr"); + return Array.from(rows).map((row) => row.querySelector("td")!.textContent!.trim()); +} + +/** Get the shadow DOM button inside a goa-table-sort-header, queried from the light DOM container. */ +function getSortHeaderButton(container: HTMLElement, name: string): HTMLElement { + const header = container.querySelector(`goa-table-sort-header[name="${name}"]`); + return header!.shadowRoot!.querySelector("button")!; +} + +/** Wait for sort headers to be mounted and their shadow roots to be ready. */ +async function waitForHeaders(container: HTMLElement, count: number) { + await vi.waitFor( + () => { + const headers = container.querySelectorAll("goa-table-sort-header"); + expect(headers.length).toBe(count); + // Ensure shadow roots are ready + headers.forEach((h) => { + expect(h.shadowRoot).toBeTruthy(); + expect(h.shadowRoot!.querySelector("button")).toBeTruthy(); + }); + }, + { timeout: 3000 }, + ); +} + +describe("GoabxTable Browser Tests", () => { + describe("single-column sorting", () => { + it("should sort by name ascending on first click", async () => { + const onSort = vi.fn(); + + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + onSort(detail); + }} + > + + + + Name + + + Department + + + Salary + + + + + {sorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + await waitForHeaders(container, 3); + + // Initial order should be the original data order + expect(getRowNames(container)).toEqual([ + "Alice Johnson", + "Bob Smith", + "Carol Williams", + "David Brown", + "Eve Davis", + ]); + + // Click Name header to sort ascending + getSortHeaderButton(container, "name").click(); + + await vi.waitFor(() => { + expect(onSort).toHaveBeenCalled(); + expect(getRowNames(container)).toEqual([ + "Alice Johnson", + "Bob Smith", + "Carol Williams", + "David Brown", + "Eve Davis", + ]); + }); + }); + + it("should toggle sort direction on subsequent clicks", async () => { + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + }} + > + + + + Name + + + Salary + + + + + {sorted.map((row) => ( + + {row.name} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + await waitForHeaders(container, 2); + + // First click — sort ascending + getSortHeaderButton(container, "name").click(); + await vi.waitFor(() => { + expect(getRowNames(container)).toEqual([ + "Alice Johnson", + "Bob Smith", + "Carol Williams", + "David Brown", + "Eve Davis", + ]); + }); + + // Second click — sort descending + getSortHeaderButton(container, "name").click(); + await vi.waitFor(() => { + expect(getRowNames(container)).toEqual([ + "Eve Davis", + "David Brown", + "Carol Williams", + "Bob Smith", + "Alice Johnson", + ]); + }); + }); + + it("should switch sort column when clicking a different header", async () => { + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + }} + > + + + + Name + + + Salary + + + + + {sorted.map((row) => ( + + {row.name} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + await waitForHeaders(container, 2); + + // Sort by name first + getSortHeaderButton(container, "name").click(); + await vi.waitFor(() => { + expect(getRowNames(container)[0]).toBe("Alice Johnson"); + }); + + // Now click salary — should sort by salary ascending, replacing name sort + getSortHeaderButton(container, "salary").click(); + await vi.waitFor(() => { + // Salary asc: 68000, 72000, 78000, 95000, 105000 + expect(getRowNames(container)).toEqual([ + "David Brown", + "Bob Smith", + "Eve Davis", + "Alice Johnson", + "Carol Williams", + ]); + }); + }); + }); + + describe("multi-column sorting", () => { + it("should sort by two columns when clicking sequentially", async () => { + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + }} + > + + + + Name + + + Department + + + Salary + + + + + {sorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + await waitForHeaders(container, 3); + + // Click department to sort ascending + getSortHeaderButton(container, "department").click(); + await vi.waitFor(() => { + // Department asc: Engineering (Alice, Carol), Marketing (Bob, Eve), Sales (David) + const names = getRowNames(container); + expect(names[0]).toBe("Alice Johnson"); // Engineering + expect(names[4]).toBe("David Brown"); // Sales + }); + + // Click salary as secondary sort + getSortHeaderButton(container, "salary").click(); + await vi.waitFor(() => { + // Dept asc then salary asc within each dept + // Engineering: Alice (95k), Carol (105k) + // Marketing: Bob (72k), Eve (78k) + // Sales: David (68k) + expect(getRowNames(container)).toEqual([ + "Alice Johnson", + "Carol Williams", + "Bob Smith", + "Eve Davis", + "David Brown", + ]); + }); + }); + }); + + describe("declarative initial sort (multi)", () => { + it("should apply initial sort from direction and sortOrder props", async () => { + const onSort = vi.fn(); + + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + onSort(detail); + }} + > + + + + Name + + + + Department + + + + + Salary + + + + + + {sorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + + // Wait for initial sort to be applied + await vi.waitFor( + () => { + // Dept asc, then salary desc within each dept + // Engineering: Carol (105k), Alice (95k) + // Marketing: Eve (78k), Bob (72k) + // Sales: David (68k) + expect(getRowNames(container)).toEqual([ + "Carol Williams", + "Alice Johnson", + "Eve Davis", + "Bob Smith", + "David Brown", + ]); + }, + { timeout: 3000 }, + ); + + // Verify the initial onSort was dispatched + expect(onSort).toHaveBeenCalled(); + const lastCall = onSort.mock.calls[onSort.mock.calls.length - 1][0]; + expect(lastCall.sorts).toEqual([ + { column: "department", direction: "asc" }, + { column: "salary", direction: "desc" }, + ]); + }); + + it("should update sort when clicking a header after initial sort", async () => { + const Component = () => { + const [sorts, setSorts] = useState([]); + const sorted = useMemo(() => sortData(initialData, sorts), [sorts]); + + return ( + { + setSorts(detail.sorts ?? []); + }} + > + + + + Name + + + + Department + + + + + Salary + + + + + + {sorted.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + ); + }; + + const { container } = render(); + + // Wait for initial sort to apply + await vi.waitFor( + () => { + expect(getRowNames(container)[0]).toBe("Carol Williams"); + }, + { timeout: 3000 }, + ); + + // Click Name header — should add name as a sort column (replacing secondary since max is 2) + getSortHeaderButton(container, "name").click(); + + await vi.waitFor(() => { + // Department asc is primary, name asc replaces salary as secondary + // Engineering: Alice, Carol + // Marketing: Bob, Eve + // Sales: David + expect(getRowNames(container)).toEqual([ + "Alice Johnson", + "Carol Williams", + "Bob Smith", + "Eve Davis", + "David Brown", + ]); + }); + }); + }); + + describe("sort header direction attribute", () => { + it("should update direction attribute on sort header after clicking", async () => { + const Component = () => { + return ( + { /* noop */ }}> + + + + Name + + + Department + + + + + + Alice + Engineering + + + + ); + }; + + const { container } = render(); + await waitForHeaders(container, 2); + + const nameHeader = container.querySelector('goa-table-sort-header[name="name"]')!; + const deptHeader = container.querySelector('goa-table-sort-header[name="department"]')!; + + // Initially both should be "none" + expect(nameHeader.getAttribute("direction")).toBe("none"); + expect(deptHeader.getAttribute("direction")).toBe("none"); + + // Click name header + getSortHeaderButton(container, "name").click(); + + await vi.waitFor(() => { + expect(nameHeader.getAttribute("direction")).toBe("asc"); + expect(deptHeader.getAttribute("direction")).toBe("none"); + }); + + // Click again for descending + getSortHeaderButton(container, "name").click(); + + await vi.waitFor(() => { + expect(nameHeader.getAttribute("direction")).toBe("desc"); + expect(deptHeader.getAttribute("direction")).toBe("none"); + }); + + // Click department — name should reset to none + getSortHeaderButton(container, "department").click(); + + await vi.waitFor(() => { + expect(nameHeader.getAttribute("direction")).toBe("none"); + expect(deptHeader.getAttribute("direction")).toBe("asc"); + }); + }); + }); +}); diff --git a/libs/react-components/src/experimental/index.ts b/libs/react-components/src/experimental/index.ts index 84f2eb6b0..7f50fb313 100644 --- a/libs/react-components/src/experimental/index.ts +++ b/libs/react-components/src/experimental/index.ts @@ -26,7 +26,7 @@ export * from "./side-menu/side-menu"; export * from "./side-menu-group/side-menu-group"; export * from "./side-menu-heading/side-menu-heading"; export * from "./table/table"; -export * from "./table/table-sort-header"; +export * from "./table-sort-header/table-sort-header"; export * from "./tab/tab"; export * from "./tabs/tabs"; export * from "./textarea/textarea"; diff --git a/libs/react-components/src/experimental/table/table-sort-header.spec.tsx b/libs/react-components/src/experimental/table-sort-header/table-sort-header.spec.tsx similarity index 67% rename from libs/react-components/src/experimental/table/table-sort-header.spec.tsx rename to libs/react-components/src/experimental/table-sort-header/table-sort-header.spec.tsx index 8e0d0ed40..f866f8241 100644 --- a/libs/react-components/src/experimental/table/table-sort-header.spec.tsx +++ b/libs/react-components/src/experimental/table-sort-header/table-sort-header.spec.tsx @@ -19,4 +19,12 @@ describe("GoabxTableSortHeader", () => { const el = document.querySelector("goa-table-sort-header"); expect(el?.getAttribute("data-grid")).toBe("cell"); }); + + it("should render sort-order attribute", () => { + render(); + const el = document.querySelector("goa-table-sort-header"); + expect(el?.getAttribute("sort-order")).toBe("2"); + expect(el?.getAttribute("name")).toBe("salary"); + expect(el?.getAttribute("direction")).toBe("desc"); + }); }); diff --git a/libs/react-components/src/experimental/table/table-sort-header.tsx b/libs/react-components/src/experimental/table-sort-header/table-sort-header.tsx similarity index 63% rename from libs/react-components/src/experimental/table/table-sort-header.tsx rename to libs/react-components/src/experimental/table-sort-header/table-sort-header.tsx index a1845a9f7..e44c2c3a5 100644 --- a/libs/react-components/src/experimental/table/table-sort-header.tsx +++ b/libs/react-components/src/experimental/table-sort-header/table-sort-header.tsx @@ -1,11 +1,11 @@ -import { DataAttributes, GoabTableSortDirection } from "@abgov/ui-components-common"; +import { DataAttributes, GoabTableSortDirection, GoabTableSortOrder } from "@abgov/ui-components-common"; import type { JSX } from "react"; -import { transformProps, lowercase } from "../../lib/common/extract-props"; interface WCProps { name?: string; direction?: GoabTableSortDirection; + "sort-order"?: GoabTableSortOrder; } declare module "react" { @@ -21,16 +21,22 @@ declare module "react" { export interface GoabxTableSortProps extends DataAttributes { name?: string; direction?: GoabTableSortDirection; + sortOrder?: GoabTableSortOrder; children?: React.ReactNode; } export function GoabxTableSortHeader({ + name, + direction = "none", + sortOrder, children, ...rest }: GoabxTableSortProps): JSX.Element { - const _props = transformProps(rest, lowercase); - - return {children}; + return ( + + {children} + + ); } export default GoabxTableSortHeader; diff --git a/libs/react-components/src/experimental/table/table.spec.tsx b/libs/react-components/src/experimental/table/table.spec.tsx index 30c9effc7..2ac994f28 100644 --- a/libs/react-components/src/experimental/table/table.spec.tsx +++ b/libs/react-components/src/experimental/table/table.spec.tsx @@ -12,13 +12,11 @@ describe("GoabxTable", () => { }); it("should render with properties", () => { - const { baseElement } = render(); + const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - expect(table?.getAttribute("stickyHeader")).toBeNull(); expect(table?.getAttribute("striped")).toBe("true"); - // TODO: Enable this later if needed - // expect(table?.getAttribute("stickyHeader")).toBe("true"); + expect(table?.getAttribute("sort-mode")).toBe("multi"); }); it("should call onSort when _sort event is triggered", () => { @@ -26,11 +24,11 @@ describe("GoabxTable", () => { const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - const event: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; + const detail: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; expect(table).toBeTruthy(); - table && fireEvent(table, new CustomEvent("_sort", { detail: event })); - expect(onSort).toHaveBeenCalledWith(event); + table && fireEvent(table, new CustomEvent("_sort", { detail })); + expect(onSort).toHaveBeenCalledWith(detail); }); it("should handle _sort event gracefully when no onSort prop is passed", () => { diff --git a/libs/react-components/src/experimental/table/table.tsx b/libs/react-components/src/experimental/table/table.tsx index 33552ab7f..96299c3b7 100644 --- a/libs/react-components/src/experimental/table/table.tsx +++ b/libs/react-components/src/experimental/table/table.tsx @@ -1,5 +1,6 @@ import { GoabTableOnSortDetail, + GoabTableSortMode, GoabTableVariant, Margins, } from "@abgov/ui-components-common"; @@ -10,6 +11,7 @@ interface WCProps extends Margins { width?: string; stickyheader?: string; variant?: GoabTableVariant; + "sort-mode"?: GoabTableSortMode; testid?: string; striped?: string; version?: string; @@ -28,6 +30,7 @@ declare module "react" { export interface GoabxTableProps extends Margins { width?: string; onSort?: (detail: GoabTableOnSortDetail) => void; + sortMode?: GoabTableSortMode; // stickyHeader?: boolean; TODO: enable this later variant?: GoabTableVariant; striped?: boolean; @@ -39,7 +42,7 @@ export interface GoabxTableProps extends Margins { // legacy name export type TableProps = GoabxTableProps; -export function GoabxTable({ onSort, version = "2", ...props }: GoabxTableProps) { +export function GoabxTable({ onSort, sortMode, version = "2", ...props }: GoabxTableProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) { @@ -64,6 +67,7 @@ export function GoabxTable({ onSort, version = "2", ...props }: GoabxTableProps) // TODO: Enable this later if needed // stickyheader={props.stickyHeader ? "true" : undefined} variant={props.variant} + sort-mode={sortMode} striped={props.striped ? "true" : undefined} testid={props.testId} mt={props.mt} diff --git a/libs/web-components/src/components/table/Table.svelte b/libs/web-components/src/components/table/Table.svelte index cbc0135f8..508378800 100644 --- a/libs/web-components/src/components/table/Table.svelte +++ b/libs/web-components/src/components/table/Table.svelte @@ -1,19 +1,26 @@ - + diff --git a/libs/web-components/src/components/table/TableSortHeader.spec.ts b/libs/web-components/src/components/table/TableSortHeader.spec.ts index 61c327b6c..d7b4fbb9a 100644 --- a/libs/web-components/src/components/table/TableSortHeader.spec.ts +++ b/libs/web-components/src/components/table/TableSortHeader.spec.ts @@ -16,7 +16,7 @@ describe("GoATableSortHeader", () => { const icon = container.querySelector("goa-icon"); expect(button.classList.contains("asc")); - expect(icon.getAttribute("type")).toBe("caret-up") + expect(icon.getAttribute("type")).toBe("arrow-up") }) it('binds desc direction param', async () => { @@ -26,6 +26,6 @@ describe("GoATableSortHeader", () => { const icon = container.querySelector("goa-icon"); expect(button.classList.contains("desc")); - expect(icon.getAttribute("type")).toBe("caret-down") + expect(icon.getAttribute("type")).toBe("arrow-down") }) }) diff --git a/libs/web-components/src/components/table/TableSortHeader.svelte b/libs/web-components/src/components/table/TableSortHeader.svelte index f4a751f4a..bb865e1a0 100644 --- a/libs/web-components/src/components/table/TableSortHeader.svelte +++ b/libs/web-components/src/components/table/TableSortHeader.svelte @@ -1,48 +1,85 @@ - + -