From 837654f9f12d660f00d7c0d4085f75e263061730 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 9 Feb 2026 18:11:02 -0700 Subject: [PATCH 1/2] feat(#3344): add multi-column sorting to Table and TableSortHeader - Add sortMode prop ("single" | "multi") to Table for multi-column sorting - Add initialSort prop to pre-configure sort state - Add onSortChange callback returning array of current sorts - Add sortOrder prop to TableSortHeader for priority display ("1", "2") - Table manages sort state internally, updates headers automatically - Change sort icons: chevron-expand (unsorted), arrow-up/down (sorted) - Add V2 focus styling (ring on icon only) - Update React and Angular wrappers (lib + experimental) - Add test pages for React and Angular playgrounds Closes #3344 --- apps/prs/angular/src/app/app.component.html | 1 + apps/prs/angular/src/app/app.routes.ts | 2 + .../features/feat3344/feat3344.component.html | 103 ++++++++ .../features/feat3344/feat3344.component.ts | 55 +++++ apps/prs/react/src/app/app.tsx | 5 +- apps/prs/react/src/main.tsx | 12 +- .../react/src/routes/features/feat3344.tsx | 225 ++++++++++++++++++ apps/prs/react/src/styles.css | 7 + .../src/experimental/table/table.spec.ts | 2 +- .../src/experimental/table/table.ts | 38 ++- .../table-sort-header/table-sort-header.ts | 3 + .../src/lib/components/table/table.spec.ts | 2 +- .../src/lib/components/table/table.ts | 38 ++- libs/common/src/lib/common.ts | 11 + .../experimental/table/table-sort-header.tsx | 14 +- .../src/experimental/table/table.spec.tsx | 9 +- .../src/experimental/table/table.tsx | 35 ++- .../src/lib/table/table-sort-header.tsx | 14 +- .../src/lib/table/table.spec.tsx | 9 +- libs/react-components/src/lib/table/table.tsx | 35 ++- .../src/components/table/Table.svelte | 168 ++++++++++--- .../components/table/TableSortHeader.spec.ts | 4 +- .../components/table/TableSortHeader.svelte | 104 +++++--- 23 files changed, 788 insertions(+), 108 deletions(-) create mode 100644 apps/prs/angular/src/routes/features/feat3344/feat3344.component.html create mode 100644 apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts create mode 100644 apps/prs/react/src/routes/features/feat3344.tsx diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index dff506719..9188e4eba 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -80,6 +80,7 @@ 2829 3102 3229 MenuButton vs size and icon-only + 3344 Table Multi-Sort 3137 3241 3306 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..2d7f11f90 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html @@ -0,0 +1,103 @@ +

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)
+ - initialSort prop for pre-configuration
+ - onSortChange callback with 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 + + + + + + {{ 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 + + + + + + {{ row.name }} + {{ row.department }} + {{ formatCurrency(row.salary) }} + + + + + + +

Test 3: Initial Sort

+

Table can be pre-configured with initialSort. Department starts sorted ascending.

+ + + + + + Name + + + Department + + + Salary + + + + + + {{ 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..8f3b3ef73 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts @@ -0,0 +1,55 @@ +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + GoabBlock, + GoabText, + GoabDivider, + GoabDetails, + GoabTable, + GoabTableSortHeader, +} from "@abgov/angular-components"; +import { GoabTableSortEntry } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "abgov-feat3344", + templateUrl: "./feat3344.component.html", + imports: [ + CommonModule, + GoabBlock, + GoabText, + GoabDivider, + GoabDetails, + GoabTable, + GoabTableSortHeader, + ], +}) +export class Feat3344Component { + currentSorts: GoabTableSortEntry[] = []; + multiSorts: 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 }, + ]; + + formatSorts(sorts: GoabTableSortEntry[]): string { + if (sorts.length === 0) return "None"; + return sorts.map((s, i) => `${i + 1}. ${s.column} (${s.direction})`).join(", "); + } + + onSingleSortChange(sorts: GoabTableSortEntry[]) { + this.currentSorts = sorts; + } + + onMultiSortChange(sorts: GoabTableSortEntry[]) { + this.multiSorts = sorts; + } + + formatCurrency(value: number): string { + return "$" + value.toLocaleString(); + } +} 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..acb33251e --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3344.tsx @@ -0,0 +1,225 @@ +/** + * 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 { useState } from "react"; +import { + GoabBlock, + GoabText, + GoabDivider, + GoabDetails, + GoabLink, + GoabTable, + GoabTableSortHeader, +} from "@abgov/react-components"; +import { + GoabxTable, + GoabxTableSortHeader, +} from "@abgov/react-components/experimental"; +import { GoabTableSortEntry } from "@abgov/ui-components-common"; + +export function Feat3344Route() { + // For displaying current sort state in the UI + const [currentSorts, setCurrentSorts] = useState([]); + const [multiSorts, setMultiSorts] = useState([]); + const [v2Sorts, setV2Sorts] = useState([]); + + // Sample data + const 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 }, + ]; + + 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 + + + + + + View on GitHub (#3344) + + + + + + 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
+ - V2: Focus ring on icon only, not whole button
+
+ Table:
+ - V2 row hover states
+ - V2 row selected states with left border indicator +
+
+
+ + + + Test 1: Single-Column Sort (Default) + + Click column headers to sort. Only one column sorts at a time (default behavior). + Table manages the sort state internally - app just receives onSortChange callback. + + + Current sort: {formatSorts(currentSorts)} + + + setCurrentSorts(sorts)}> + + + + Name + + + Department + + + Salary + + + + + {data.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). + Numbers appear showing sort priority. Click to toggle direction, click again to remove. + + + Current sort: {formatSorts(multiSorts)} + + + setMultiSorts(sorts)}> + + + + Name + + + Department + + + Salary + + + + + {data.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + + + + Test 3: Initial Sort (optional) + + Table can be pre-configured with initialSort. Department starts sorted ascending. + + + console.log("Test 3 sorts:", sorts)} + > + + + + Name + + + Department + + + Salary + + + + + {data.map((row) => ( + + {row.name} + {row.department} + ${row.salary.toLocaleString()} + + ))} + + + + + + Test 4: V2 Experimental Wrapper + + Using GoabxTable and GoabxTableSortHeader from experimental. + V2 styling: focus ring on icon only, updated hover states. + + + Current sort: {formatSorts(v2Sorts)} + + + setV2Sorts(sorts)}> + + + + Name + + + Department + + + Salary + + + + + {data.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/libs/angular-components/src/experimental/table/table.spec.ts b/libs/angular-components/src/experimental/table/table.spec.ts index 11ce0650b..4d670eedc 100644 --- a/libs/angular-components/src/experimental/table/table.spec.ts +++ b/libs/angular-components/src/experimental/table/table.spec.ts @@ -98,7 +98,7 @@ describe("GoabxTable", () => { fireEvent( el, new CustomEvent("_sort", { - detail: { sortBy: "column1", sortDir: 1 }, + detail: { sorts: [{ column: "column1", direction: "asc" }] }, }), ); diff --git a/libs/angular-components/src/experimental/table/table.ts b/libs/angular-components/src/experimental/table/table.ts index e57e05cb1..1f278610b 100644 --- a/libs/angular-components/src/experimental/table/table.ts +++ b/libs/angular-components/src/experimental/table/table.ts @@ -1,4 +1,10 @@ -import { GoabTableOnSortDetail, GoabTableVariant } from "@abgov/ui-components-common"; +import { + GoabTableOnSortDetail, + GoabTableSortChangeDetail, + GoabTableSortEntry, + GoabTableSortMode, + GoabTableVariant, +} from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -21,6 +27,8 @@ import { GoabBaseComponent } from "../base.component"; [attr.version]="version" [attr.width]="width" [attr.variant]="variant" + [attr.sortmode]="sortMode" + [attr.initialsort]="initialSortJson" [attr.striped]="striped" [attr.testid]="testId" [attr.mt]="mt" @@ -42,8 +50,16 @@ export class GoabxTable extends GoabBaseComponent implements OnInit { version = "2"; @Input() width?: string; @Input() variant?: GoabTableVariant; + /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ + @Input() sortMode?: GoabTableSortMode; + /** Initial sort configuration */ + @Input() initialSort?: GoabTableSortEntry[]; @Input({ transform: booleanAttribute }) striped?: boolean; + get initialSortJson(): string | undefined { + return this.initialSort ? JSON.stringify(this.initialSort) : undefined; + } + constructor(private cdr: ChangeDetectorRef) { super(); } @@ -55,10 +71,26 @@ export class GoabxTable extends GoabBaseComponent implements OnInit { }); } + /** @deprecated Use onSortChange for new implementations */ @Output() onSort = new EventEmitter(); + /** Called when sort state changes. Receives array of current sorts. */ + @Output() onSortChange = new EventEmitter(); _onSort(e: Event) { - const detail = (e as CustomEvent).detail; - this.onSort.emit(detail); + const detail = (e as CustomEvent).detail; + + // New API: onSortChange receives sorts array + if ("sorts" in detail) { + this.onSortChange.emit(detail.sorts); + + // Legacy API: translate new format for backward compat + if (detail.sorts.length > 0) { + const firstSort = detail.sorts[0]; + this.onSort.emit({ + sortBy: firstSort.column, + sortDir: firstSort.direction === "asc" ? 1 : -1, + }); + } + } } } 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..d0eaa6d50 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 @@ -10,6 +10,7 @@ import { CommonModule } from "@angular/common"; *ngIf="isReady" [attr.name]="name" [attr.direction]="direction" + [attr.sortorder]="sortOrder" > @@ -21,6 +22,8 @@ export class GoabTableSortHeader implements OnInit { isReady = false; @Input() name?: string; @Input() direction?: GoabTableSortDirection = "none"; + /** Sort order number for multi-column sort display ("1", "2", etc). */ + @Input() sortOrder?: string; constructor(private cdr: ChangeDetectorRef) {} diff --git a/libs/angular-components/src/lib/components/table/table.spec.ts b/libs/angular-components/src/lib/components/table/table.spec.ts index c67aaa440..75ebbb0d0 100644 --- a/libs/angular-components/src/lib/components/table/table.spec.ts +++ b/libs/angular-components/src/lib/components/table/table.spec.ts @@ -98,7 +98,7 @@ describe("GoabTable", () => { fireEvent( el, new CustomEvent("_sort", { - detail: { sortBy: "column1", sortDir: 1 }, + detail: { sorts: [{ column: "column1", direction: "asc" }] }, }), ); diff --git a/libs/angular-components/src/lib/components/table/table.ts b/libs/angular-components/src/lib/components/table/table.ts index a3a1bc077..b6a714902 100644 --- a/libs/angular-components/src/lib/components/table/table.ts +++ b/libs/angular-components/src/lib/components/table/table.ts @@ -1,4 +1,10 @@ -import { GoabTableOnSortDetail, GoabTableVariant } from "@abgov/ui-components-common"; +import { + GoabTableOnSortDetail, + GoabTableSortChangeDetail, + GoabTableSortEntry, + GoabTableSortMode, + GoabTableVariant, +} from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -19,6 +25,8 @@ import { GoabBaseComponent } from "../base.component"; *ngIf="isReady" [attr.width]="width" [attr.variant]="variant" + [attr.sortmode]="sortMode" + [attr.initialsort]="initialSortJson" [attr.testid]="testId" [attr.mt]="mt" [attr.mb]="mb" @@ -38,6 +46,14 @@ export class GoabTable extends GoabBaseComponent implements OnInit { isReady = false; @Input() width?: string; @Input() variant?: GoabTableVariant; + /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ + @Input() sortMode?: GoabTableSortMode; + /** Initial sort configuration */ + @Input() initialSort?: GoabTableSortEntry[]; + + get initialSortJson(): string | undefined { + return this.initialSort ? JSON.stringify(this.initialSort) : undefined; + } constructor(private cdr: ChangeDetectorRef) { super(); @@ -50,10 +66,26 @@ export class GoabTable extends GoabBaseComponent implements OnInit { }); } + /** @deprecated Use onSortChange for new implementations */ @Output() onSort = new EventEmitter(); + /** Called when sort state changes. Receives array of current sorts. */ + @Output() onSortChange = new EventEmitter(); _onSort(e: Event) { - const detail = (e as CustomEvent).detail; - this.onSort.emit(detail); + const detail = (e as CustomEvent).detail; + + // New API: onSortChange receives sorts array + if ("sorts" in detail) { + this.onSortChange.emit(detail.sorts); + + // Legacy API: translate new format for backward compat + if (detail.sorts.length > 0) { + const firstSort = detail.sorts[0]; + this.onSort.emit({ + sortBy: firstSort.column, + sortDir: firstSort.direction === "asc" ? 1 : -1, + }); + } + } } } diff --git a/libs/common/src/lib/common.ts b/libs/common/src/lib/common.ts index 929677f21..a31c1b40a 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -247,6 +247,17 @@ export type GoabTableOnSortDetail = { sortDir: number; }; +export type GoabTableSortMode = "single" | "multi"; + +export type GoabTableSortEntry = { + column: string; + direction: "asc" | "desc"; +}; + +export type GoabTableSortChangeDetail = { + sorts: GoabTableSortEntry[]; +}; + // Spacer export type GoabSpacerHorizontalSpacing = Spacing | "fill"; diff --git a/libs/react-components/src/experimental/table/table-sort-header.tsx b/libs/react-components/src/experimental/table/table-sort-header.tsx index a1845a9f7..521bb54b5 100644 --- a/libs/react-components/src/experimental/table/table-sort-header.tsx +++ b/libs/react-components/src/experimental/table/table-sort-header.tsx @@ -1,11 +1,11 @@ import { DataAttributes, GoabTableSortDirection } from "@abgov/ui-components-common"; import type { JSX } from "react"; -import { transformProps, lowercase } from "../../lib/common/extract-props"; interface WCProps { name?: string; direction?: GoabTableSortDirection; + sortorder?: string; } declare module "react" { @@ -21,16 +21,22 @@ declare module "react" { export interface GoabxTableSortProps extends DataAttributes { name?: string; direction?: GoabTableSortDirection; + sortOrder?: string; 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..4538b2006 100644 --- a/libs/react-components/src/experimental/table/table.spec.tsx +++ b/libs/react-components/src/experimental/table/table.spec.tsx @@ -26,11 +26,14 @@ describe("GoabxTable", () => { const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - const event: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; + // New event format with sorts array + const eventDetail = { sorts: [{ column: "name", direction: "asc" }] }; + // Legacy callback receives translated format + const expectedLegacy: 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: eventDetail })); + expect(onSort).toHaveBeenCalledWith(expectedLegacy); }); 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..e716472df 100644 --- a/libs/react-components/src/experimental/table/table.tsx +++ b/libs/react-components/src/experimental/table/table.tsx @@ -1,5 +1,8 @@ import { GoabTableOnSortDetail, + GoabTableSortChangeDetail, + GoabTableSortEntry, + GoabTableSortMode, GoabTableVariant, Margins, } from "@abgov/ui-components-common"; @@ -10,6 +13,8 @@ interface WCProps extends Margins { width?: string; stickyheader?: string; variant?: GoabTableVariant; + sortmode?: string; + initialsort?: string; testid?: string; striped?: string; version?: string; @@ -27,7 +32,14 @@ declare module "react" { /* eslint-disable-next-line */ export interface GoabxTableProps extends Margins { width?: string; + /** @deprecated Use onSortChange for new implementations */ onSort?: (detail: GoabTableOnSortDetail) => void; + /** Called when sort state changes. Receives array of current sorts. */ + onSortChange?: (sorts: GoabTableSortEntry[]) => void; + /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ + sortMode?: GoabTableSortMode; + /** Initial sort configuration */ + initialSort?: GoabTableSortEntry[]; // stickyHeader?: boolean; TODO: enable this later variant?: GoabTableVariant; striped?: boolean; @@ -39,7 +51,7 @@ export interface GoabxTableProps extends Margins { // legacy name export type TableProps = GoabxTableProps; -export function GoabxTable({ onSort, version = "2", ...props }: GoabxTableProps) { +export function GoabxTable({ onSort, onSortChange, sortMode, initialSort, version = "2", ...props }: GoabxTableProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) { @@ -47,15 +59,28 @@ export function GoabxTable({ onSort, version = "2", ...props }: GoabxTableProps) } const current = ref.current; const sortListener = (e: unknown) => { - const detail = (e as CustomEvent).detail; - onSort?.(detail); + const detail = (e as CustomEvent).detail; + + // New API: onSortChange receives sorts array + if (onSortChange && "sorts" in detail) { + onSortChange(detail.sorts); + } + + // Legacy API: translate new format for backward compat + if (onSort && "sorts" in detail && detail.sorts.length > 0) { + const firstSort = detail.sorts[0]; + onSort({ + sortBy: firstSort.column, + sortDir: firstSort.direction === "asc" ? 1 : -1, + }); + } }; current.addEventListener("_sort", sortListener); return () => { current.removeEventListener("_sort", sortListener); }; - }, [ref, onSort]); + }, [ref, onSort, onSortChange]); return ( (rest, lowercase); - return ( - + {children} ); diff --git a/libs/react-components/src/lib/table/table.spec.tsx b/libs/react-components/src/lib/table/table.spec.tsx index d9d38a2f7..3556b1777 100644 --- a/libs/react-components/src/lib/table/table.spec.tsx +++ b/libs/react-components/src/lib/table/table.spec.tsx @@ -25,11 +25,14 @@ describe("Table", () => { const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - const event: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; + // New event format with sorts array + const eventDetail = { sorts: [{ column: "name", direction: "asc" }] }; + // Legacy callback receives translated format + const expectedLegacy: 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: eventDetail })); + expect(onSort).toHaveBeenCalledWith(expectedLegacy); }); it("should handle _sort event gracefully when no onSort prop is passed", () => { diff --git a/libs/react-components/src/lib/table/table.tsx b/libs/react-components/src/lib/table/table.tsx index 659c6b6bc..cece4b667 100644 --- a/libs/react-components/src/lib/table/table.tsx +++ b/libs/react-components/src/lib/table/table.tsx @@ -1,14 +1,26 @@ import { GoabTableOnSortDetail, + GoabTableSortChangeDetail, + GoabTableSortEntry, + GoabTableSortMode, GoabTableVariant, Margins, } from "@abgov/ui-components-common"; import { ReactNode, useEffect, useRef } from "react"; +// Note: JSX.IntrinsicElements for "goa-table" is declared in experimental/table/table.tsx + /* eslint-disable-next-line */ export interface GoabTableProps extends Margins { width?: string; + /** @deprecated Use onSortChange for new implementations */ onSort?: (detail: GoabTableOnSortDetail) => void; + /** Called when sort state changes. Receives array of current sorts. */ + onSortChange?: (sorts: GoabTableSortEntry[]) => void; + /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ + sortMode?: GoabTableSortMode; + /** Initial sort configuration */ + initialSort?: GoabTableSortEntry[]; // stickyHeader?: boolean; TODO: enable this later variant?: GoabTableVariant; testId?: string; @@ -18,7 +30,7 @@ export interface GoabTableProps extends Margins { // legacy name export type TableProps = GoabTableProps; -export function GoabTable({ onSort, ...props }: GoabTableProps) { +export function GoabTable({ onSort, onSortChange, sortMode, initialSort, ...props }: GoabTableProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) { @@ -26,15 +38,28 @@ export function GoabTable({ onSort, ...props }: GoabTableProps) { } const current = ref.current; const sortListener = (e: unknown) => { - const detail = (e as CustomEvent).detail; - onSort?.(detail); + const detail = (e as CustomEvent).detail; + + // New API: onSortChange receives sorts array + if (onSortChange && "sorts" in detail) { + onSortChange(detail.sorts); + } + + // Legacy API: translate new format for backward compat + if (onSort && "sorts" in detail && detail.sorts.length > 0) { + const firstSort = detail.sorts[0]; + onSort({ + sortBy: firstSort.column, + sortDir: firstSort.direction === "asc" ? 1 : -1, + }); + } }; current.addEventListener("_sort", sortListener); return () => { current.removeEventListener("_sort", sortListener); }; - }, [ref, onSort]); + }, [ref, onSort, onSortChange]); return ( @@ -14,6 +15,10 @@ import type { GoATableSortDirection } from "./TableSortHeader.svelte"; import type { Spacing } from "../../common/styling"; + // Types + type SortDirection = "asc" | "desc"; + type SortEntry = { column: string; direction: SortDirection }; + // Validators const [Variants, validateVariant] = typeValidator( "Table variant", @@ -25,6 +30,13 @@ const [Version, validateVersion] = typeValidator("Version", ["1", "2"]); type VersionType = (typeof Version)[number]; + const [SortModes, validateSortMode] = typeValidator( + "Sort mode", + ["single", "multi"], + true, + ); + type SortMode = (typeof SortModes)[number]; + // Public /** Width of the table. By default it will fit the enclosed content. */ @@ -39,6 +51,10 @@ export let version: VersionType = "1"; /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Sort mode: "single" allows one column, "multi" allows up to 2 columns. */ + export let sortmode: SortMode = "single"; + /** Initial sort configuration as JSON string: [{"column":"name","direction":"asc"}] */ + export let initialsort: string = ""; /** Top margin. */ export let mt: Spacing = null; @@ -53,6 +69,9 @@ let _rootEl: HTMLElement; let _isTableRoot: boolean = false; + let _sorts: SortEntry[] = []; + let _headings: NodeListOf | null = null; + const _maxSorts = 2; // Reactive @@ -64,6 +83,7 @@ onMount(() => { validateVariant(variant); validateVersion(version); + validateSortMode(sortmode); // without setTimeout it won't properly sort in Safari setTimeout(attachSortEventHandling, 0); @@ -83,56 +103,134 @@ async function attachSortEventHandling() { await tick(); const contentSlot = _rootEl?.querySelector("slot") as HTMLSlotElement; - const headings = contentSlot + _headings = contentSlot ?.assignedElements() .find((el) => el.tagName === "THEAD" || el.tagName === "TABLE") - ?.querySelectorAll("goa-table-sort-header"); + ?.querySelectorAll("goa-table-sort-header") || null; - headings?.forEach((heading) => { + if (!_headings) return; + + // Initialize sorts: prefer initialsort prop, fall back to header direction props + initializeSorts(); + + // Attach click handlers + _headings.forEach((heading) => { heading.addEventListener("click", () => { - const sortBy = heading.getAttribute("name"); - let sortDir: number = 0; - - // relay state to all children - headings.forEach((child) => { - if (child.getAttribute("name") === sortBy) { - const direction = child["direction"] as GoATableSortDirection; - // starting direction is asc - const newDirection = direction === "asc" ? "desc" : "asc"; - - sortDir = newDirection === "asc" ? 1 : -1; - child.setAttribute("direction", newDirection); - } else { - child.setAttribute("direction", "none"); - } - }); + const column = heading.getAttribute("name"); + if (column) { + handleSortClick(column); + } + }); + }); + + // Apply initial state to headers and dispatch initial event + updateHeaderAttributes(); + if (_sorts.length > 0) { + dispatchSortEvent(); + } + } + + function initializeSorts() { + // If initialsort prop is set, use it (takes precedence) + if (initialsort) { + try { + const parsed = JSON.parse(initialsort) as SortEntry[]; + _sorts = parsed.slice(0, _maxSorts).filter( + (s) => s.column && (s.direction === "asc" || s.direction === "desc") + ); + return; + } catch (e) { + console.warn("Invalid initialsort JSON:", initialsort); + } + } - if (sortBy && sortDir !== 0) { - dispatch(heading, { sortBy, sortDir }); + // Fall back to scanning headers for direction props (backward compat) + if (_headings) { + _headings.forEach((heading) => { + const column = heading.getAttribute("name"); + const direction = (heading as any).direction as GoATableSortDirection; + if (column && direction && direction !== "none") { + if (sortmode === "single") { + _sorts = [{ column, direction }]; + } else if (_sorts.length < _maxSorts) { + _sorts.push({ column, direction }); + } } }); + } + } + + function handleSortClick(column: string) { + const existingIndex = _sorts.findIndex((s) => s.column === column); - // dispatch the default sort params if initially set - const initialSortBy = heading.getAttribute("name"); - const initialDirection = heading["direction"] as GoATableSortDirection; - if (initialSortBy && initialDirection && initialDirection !== "none") { - setTimeout(() => { - dispatch(heading, { - sortBy: initialSortBy, - sortDir: initialDirection === "asc" ? 1 : -1, - }); - }, 10); + if (sortmode === "single") { + // Single mode: toggle direction or set new column + if (existingIndex >= 0) { + const current = _sorts[existingIndex]; + if (current.direction === "asc") { + _sorts = [{ column, direction: "desc" }]; + } else { + // Was desc, go back to asc (or could clear - design choice) + _sorts = [{ column, direction: "asc" }]; + } + } else { + _sorts = [{ column, direction: "asc" }]; + } + } else { + // Multi mode: add, toggle, or remove + if (existingIndex >= 0) { + const current = _sorts[existingIndex]; + if (current.direction === "asc") { + // Toggle to desc + _sorts[existingIndex] = { column, direction: "desc" }; + _sorts = [..._sorts]; // Trigger reactivity + } else { + // Remove from sorts + _sorts = _sorts.filter((_, i) => i !== existingIndex); + } + } else if (_sorts.length < _maxSorts) { + // Add new sort + _sorts = [..._sorts, { column, direction: "asc" }]; + } else { + // At max sorts - replace the secondary (last) sort with the new column + _sorts = [_sorts[0], { column, direction: "asc" }]; + } + } + + updateHeaderAttributes(); + dispatchSortEvent(); + } + + function updateHeaderAttributes() { + if (!_headings) return; + + _headings.forEach((heading) => { + const column = heading.getAttribute("name"); + const sortIndex = _sorts.findIndex((s) => s.column === column); + + if (sortIndex >= 0) { + heading.setAttribute("direction", _sorts[sortIndex].direction); + // Only show sort order numbers in multi mode with 2+ sorts + if (sortmode === "multi" && _sorts.length > 1) { + heading.setAttribute("sortorder", String(sortIndex + 1)); + } else { + heading.setAttribute("sortorder", ""); + } + } else { + heading.setAttribute("direction", "none"); + heading.setAttribute("sortorder", ""); } }); } - function dispatch(el: Element, params: { sortBy: string; sortDir: number }) { - el.dispatchEvent( + function dispatchSortEvent() { + // Dispatch from the table element itself + _rootEl?.dispatchEvent( new CustomEvent("_sort", { composed: true, bubbles: true, cancelable: false, - detail: params, + detail: { sorts: _sorts }, }), ); } 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..b049d25fd 100644 --- a/libs/web-components/src/components/table/TableSortHeader.svelte +++ b/libs/web-components/src/components/table/TableSortHeader.svelte @@ -1,4 +1,10 @@ - + - From fa361f738d5ba4439a5a13be70e977beb7d21239 Mon Sep 17 00:00:00 2001 From: Vanessa Tran Date: Thu, 26 Feb 2026 18:05:20 -0700 Subject: [PATCH 2/2] chore: refactor --- apps/prs/angular/src/app/app.component.html | 2 +- .../features/feat3344/feat3344.component.html | 52 +- .../features/feat3344/feat3344.component.ts | 81 ++- .../react/src/routes/features/feat3344.tsx | 193 +++---- .../component-apis/table-sort-header.json | 41 ++ docs/generated/component-apis/table.json | 12 + docs/src/data/configurations/index.ts | 3 + .../data/configurations/table-sort-header.ts | 48 ++ docs/src/data/configurations/table.ts | 168 ++++++ .../table-sort-header.spec.ts | 38 ++ .../table-sort-header/table-sort-header.ts | 4 +- .../src/experimental/table/table.spec.ts | 23 +- .../src/experimental/table/table.ts | 32 +- .../table-sort-header/table-sort-header.ts | 4 - .../src/lib/components/table/table.spec.ts | 2 +- .../src/lib/components/table/table.ts | 38 +- libs/common/src/lib/common.ts | 12 +- .../specs/table.browser.spec.tsx | 531 ++++++++++++++++++ .../src/experimental/index.ts | 2 +- .../table-sort-header.spec.tsx | 8 + .../table-sort-header.tsx | 8 +- .../src/experimental/table/table.spec.tsx | 15 +- .../src/experimental/table/table.tsx | 35 +- .../src/lib/table/table-sort-header.tsx | 14 +- .../src/lib/table/table.spec.tsx | 9 +- libs/react-components/src/lib/table/table.tsx | 35 +- .../src/components/table/Table.svelte | 244 ++++---- .../components/table/TableSortHeader.svelte | 80 ++- 28 files changed, 1277 insertions(+), 457 deletions(-) create mode 100644 docs/generated/component-apis/table-sort-header.json create mode 100644 docs/src/data/configurations/table-sort-header.ts create mode 100644 libs/react-components/specs/table.browser.spec.tsx rename libs/react-components/src/experimental/{table => table-sort-header}/table-sort-header.spec.tsx (67%) rename libs/react-components/src/experimental/{table => table-sort-header}/table-sort-header.tsx (80%) diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index 9188e4eba..e7dae74f1 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -80,12 +80,12 @@ 2829 3102 3229 MenuButton vs size and icon-only - 3344 Table Multi-Sort 3137 3241 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/routes/features/feat3344/feat3344.component.html b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html index 2d7f11f90..0eda15483 100644 --- a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.html @@ -10,8 +10,8 @@

PR 4: Table & TableSortHeader V2


Table:
- sortMode="single" (default) or "multi" (up to 2 columns)
- - initialSort prop for pre-configuration
- - onSortChange callback with sorts array + - Initial sort declared on headers via direction + sortOrder
+ - onSort callback with sortBy, sortDir, and sorts array

@@ -22,28 +22,30 @@

Test 1: Single-Column Sort (Default)

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

Current sort: {{ formatSorts(currentSorts) }}

- +
- + @for (row of singleSorted; track row.id) { + + } - + @@ -51,53 +53,59 @@

Test 2: Multi-Column Sort

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

Current sort: {{ formatSorts(multiSorts) }}

- + - + @for (row of multiSorted; track row.id) { + + } - + -

Test 3: Initial Sort

-

Table can be pre-configured with initialSort. Department starts sorted ascending.

+

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) }}

- + - + @for (row of test3Sorted; track row.id) { + + } - + diff --git a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts index 8f3b3ef73..722af099e 100644 --- a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts @@ -1,32 +1,31 @@ -import { Component } from "@angular/core"; -import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy } from "@angular/core"; import { GoabBlock, - GoabText, GoabDivider, GoabDetails, - GoabTable, - GoabTableSortHeader, + GoabxTable, + GoabxTableSortHeader, } from "@abgov/angular-components"; -import { GoabTableSortEntry } from "@abgov/ui-components-common"; +import { GoabTableOnSortDetail, GoabTableSortEntry } from "@abgov/ui-components-common"; @Component({ standalone: true, selector: "abgov-feat3344", templateUrl: "./feat3344.component.html", imports: [ - CommonModule, GoabBlock, - GoabText, GoabDivider, GoabDetails, - GoabTable, - GoabTableSortHeader, + GoabxTable, + GoabxTableSortHeader, ], }) -export class Feat3344Component { +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 }, @@ -36,20 +35,66 @@ export class Feat3344Component { { id: 5, name: "Eve Davis", department: "Marketing", salary: 78000 }, ]; - formatSorts(sorts: GoabTableSortEntry[]): string { - if (sorts.length === 0) return "None"; - return sorts.map((s, i) => `${i + 1}. ${s.column} (${s.direction})`).join(", "); + 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); } - onSingleSortChange(sorts: GoabTableSortEntry[]) { - this.currentSorts = sorts; + onTest3SortChange(detail: GoabTableOnSortDetail) { + this.test3Sorts = detail.sorts ?? []; + this.test3Sorted = this.sortData(this.data, this.test3Sorts); } - onMultiSortChange(sorts: GoabTableSortEntry[]) { - this.multiSorts = sorts; + 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/routes/features/feat3344.tsx b/apps/prs/react/src/routes/features/feat3344.tsx index acb33251e..354e8adc9 100644 --- a/apps/prs/react/src/routes/features/feat3344.tsx +++ b/apps/prs/react/src/routes/features/feat3344.tsx @@ -9,36 +9,63 @@ * - V2 focus ring on icon only (not whole button) */ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { GoabBlock, - GoabText, GoabDivider, GoabDetails, - GoabLink, - GoabTable, - GoabTableSortHeader, } from "@abgov/react-components"; -import { - GoabxTable, - GoabxTableSortHeader, -} from "@abgov/react-components/experimental"; -import { GoabTableSortEntry } from "@abgov/ui-components-common"; +// ?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() { - // For displaying current sort state in the UI + 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 [v2Sorts, setV2Sorts] = useState([]); + const [test3Sorts, setTest3Sorts] = useState([]); - // Sample data - const 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 }, - ]; + 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"; @@ -47,59 +74,46 @@ export function Feat3344Route() { return (
- - PR 4: Table & TableSortHeader V2 - +

PR 4: Table & TableSortHeader V2

- - - View on GitHub (#3344) - - - - +

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
- - V2: Focus ring on icon only, not whole button

Table:
- - V2 row hover states
- - V2 row selected states with left border indicator - + - 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). - Table manages the sort state internally - app just receives onSortChange callback. - - - Current sort: {formatSorts(currentSorts)} - +

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(sorts)}> + setCurrentSorts(detail.sorts ?? [])}>
- {data.map((row) => ( + {singleSorted.map((row) => ( @@ -107,35 +121,30 @@ export function Feat3344Route() { ))} - + - Test 2: Multi-Column Sort - - With sortMode="multi", click columns to add them to sort order (up to 2). - Numbers appear showing sort priority. Click to toggle direction, click again to remove. - - - Current sort: {formatSorts(multiSorts)} - +

Test 2: Multi-Column Sort

+

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

+

Current sort: {formatSorts(multiSorts)}

- setMultiSorts(sorts)}> + setMultiSorts(detail.sorts ?? [])}>
- {data.map((row) => ( + {multiSorted.map((row) => ( @@ -143,72 +152,39 @@ export function Feat3344Route() { ))} - + - Test 3: Initial Sort (optional) - - Table can be pre-configured with initialSort. Department starts sorted ascending. - +

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)}

- console.log("Test 3 sorts:", sorts)} + onSort={(detail) => setTest3Sorts(detail.sorts ?? [])} > -
- - - - - - - - {data.map((row) => ( - - - - - - ))} - - - - - - Test 4: V2 Experimental Wrapper - - Using GoabxTable and GoabxTableSortHeader from experimental. - V2 styling: focus ring on icon only, updated hover states. - - - Current sort: {formatSorts(v2Sorts)} - - - setV2Sorts(sorts)}> - {data.map((row) => ( + {test3Sorted.map((row) => ( @@ -217,7 +193,6 @@ export function Feat3344Route() { ))} - ); } 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 = {
- Name + Name - Department + Department - Salary + Salary
{{ row.name }} {{ row.department }} {{ formatCurrency(row.salary) }}
- Name + Name - Department + Department - Salary + Salary
{{ row.name }} {{ row.department }} {{ formatCurrency(row.salary) }}
- Name + Name - Department + Department - Salary + Salary
{{ row.name }} {{ row.department }} {{ formatCurrency(row.salary) }}
- Name + Name - Department + Department - Salary + Salary
{row.name} {row.department}
- Name + Name - Department + Department - Salary + Salary
{row.name} {row.department}
- Name - - Department - - Salary -
{row.name}{row.department}${row.salary.toLocaleString()}
Name - Department + + Department + - Salary + + Salary +
{row.name} {row.department}
+
`, + }, + }, + { + 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 4d670eedc..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: { sorts: [{ column: "column1", direction: "asc" }] }, - }), + 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 1f278610b..be621400c 100644 --- a/libs/angular-components/src/experimental/table/table.ts +++ b/libs/angular-components/src/experimental/table/table.ts @@ -1,7 +1,5 @@ import { GoabTableOnSortDetail, - GoabTableSortChangeDetail, - GoabTableSortEntry, GoabTableSortMode, GoabTableVariant, } from "@abgov/ui-components-common"; @@ -27,8 +25,7 @@ import { GoabBaseComponent } from "../base.component"; [attr.version]="version" [attr.width]="width" [attr.variant]="variant" - [attr.sortmode]="sortMode" - [attr.initialsort]="initialSortJson" + [attr.sort-mode]="sortMode" [attr.striped]="striped" [attr.testid]="testId" [attr.mt]="mt" @@ -50,16 +47,9 @@ export class GoabxTable extends GoabBaseComponent implements OnInit { version = "2"; @Input() width?: string; @Input() variant?: GoabTableVariant; - /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ @Input() sortMode?: GoabTableSortMode; - /** Initial sort configuration */ - @Input() initialSort?: GoabTableSortEntry[]; @Input({ transform: booleanAttribute }) striped?: boolean; - get initialSortJson(): string | undefined { - return this.initialSort ? JSON.stringify(this.initialSort) : undefined; - } - constructor(private cdr: ChangeDetectorRef) { super(); } @@ -71,26 +61,10 @@ export class GoabxTable extends GoabBaseComponent implements OnInit { }); } - /** @deprecated Use onSortChange for new implementations */ @Output() onSort = new EventEmitter(); - /** Called when sort state changes. Receives array of current sorts. */ - @Output() onSortChange = new EventEmitter(); _onSort(e: Event) { - const detail = (e as CustomEvent).detail; - - // New API: onSortChange receives sorts array - if ("sorts" in detail) { - this.onSortChange.emit(detail.sorts); - - // Legacy API: translate new format for backward compat - if (detail.sorts.length > 0) { - const firstSort = detail.sorts[0]; - this.onSort.emit({ - sortBy: firstSort.column, - sortDir: firstSort.direction === "asc" ? 1 : -1, - }); - } - } + const detail = (e as CustomEvent).detail; + this.onSort.emit(detail); } } 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 d0eaa6d50..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 @@ -10,7 +10,6 @@ import { CommonModule } from "@angular/common"; *ngIf="isReady" [attr.name]="name" [attr.direction]="direction" - [attr.sortorder]="sortOrder" > @@ -22,9 +21,6 @@ export class GoabTableSortHeader implements OnInit { isReady = false; @Input() name?: string; @Input() direction?: GoabTableSortDirection = "none"; - /** Sort order number for multi-column sort display ("1", "2", etc). */ - @Input() sortOrder?: string; - constructor(private cdr: ChangeDetectorRef) {} ngOnInit(): void { diff --git a/libs/angular-components/src/lib/components/table/table.spec.ts b/libs/angular-components/src/lib/components/table/table.spec.ts index 75ebbb0d0..c67aaa440 100644 --- a/libs/angular-components/src/lib/components/table/table.spec.ts +++ b/libs/angular-components/src/lib/components/table/table.spec.ts @@ -98,7 +98,7 @@ describe("GoabTable", () => { fireEvent( el, new CustomEvent("_sort", { - detail: { sorts: [{ column: "column1", direction: "asc" }] }, + detail: { sortBy: "column1", sortDir: 1 }, }), ); diff --git a/libs/angular-components/src/lib/components/table/table.ts b/libs/angular-components/src/lib/components/table/table.ts index b6a714902..a3a1bc077 100644 --- a/libs/angular-components/src/lib/components/table/table.ts +++ b/libs/angular-components/src/lib/components/table/table.ts @@ -1,10 +1,4 @@ -import { - GoabTableOnSortDetail, - GoabTableSortChangeDetail, - GoabTableSortEntry, - GoabTableSortMode, - GoabTableVariant, -} from "@abgov/ui-components-common"; +import { GoabTableOnSortDetail, GoabTableVariant } from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -25,8 +19,6 @@ import { GoabBaseComponent } from "../base.component"; *ngIf="isReady" [attr.width]="width" [attr.variant]="variant" - [attr.sortmode]="sortMode" - [attr.initialsort]="initialSortJson" [attr.testid]="testId" [attr.mt]="mt" [attr.mb]="mb" @@ -46,14 +38,6 @@ export class GoabTable extends GoabBaseComponent implements OnInit { isReady = false; @Input() width?: string; @Input() variant?: GoabTableVariant; - /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ - @Input() sortMode?: GoabTableSortMode; - /** Initial sort configuration */ - @Input() initialSort?: GoabTableSortEntry[]; - - get initialSortJson(): string | undefined { - return this.initialSort ? JSON.stringify(this.initialSort) : undefined; - } constructor(private cdr: ChangeDetectorRef) { super(); @@ -66,26 +50,10 @@ export class GoabTable extends GoabBaseComponent implements OnInit { }); } - /** @deprecated Use onSortChange for new implementations */ @Output() onSort = new EventEmitter(); - /** Called when sort state changes. Receives array of current sorts. */ - @Output() onSortChange = new EventEmitter(); _onSort(e: Event) { - const detail = (e as CustomEvent).detail; - - // New API: onSortChange receives sorts array - if ("sorts" in detail) { - this.onSortChange.emit(detail.sorts); - - // Legacy API: translate new format for backward compat - if (detail.sorts.length > 0) { - const firstSort = detail.sorts[0]; - this.onSort.emit({ - sortBy: firstSort.column, - sortDir: firstSort.direction === "asc" ? 1 : -1, - }); - } - } + const detail = (e as CustomEvent).detail; + this.onSort.emit(detail); } } diff --git a/libs/common/src/lib/common.ts b/libs/common/src/lib/common.ts index a31c1b40a..3511922c3 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -242,20 +242,18 @@ export interface GoabTableProps extends Margins { testId?: string; } -export type GoabTableOnSortDetail = { - sortBy: string; - sortDir: number; -}; - export type GoabTableSortMode = "single" | "multi"; +export type GoabTableSortOrder = 1 | 2; export type GoabTableSortEntry = { column: string; direction: "asc" | "desc"; }; -export type GoabTableSortChangeDetail = { - sorts: GoabTableSortEntry[]; +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 80% 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 521bb54b5..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"; interface WCProps { name?: string; direction?: GoabTableSortDirection; - sortorder?: string; + "sort-order"?: GoabTableSortOrder; } declare module "react" { @@ -21,7 +21,7 @@ declare module "react" { export interface GoabxTableSortProps extends DataAttributes { name?: string; direction?: GoabTableSortDirection; - sortOrder?: string; + sortOrder?: GoabTableSortOrder; children?: React.ReactNode; } @@ -33,7 +33,7 @@ export function GoabxTableSortHeader({ ...rest }: GoabxTableSortProps): JSX.Element { return ( - + {children} ); diff --git a/libs/react-components/src/experimental/table/table.spec.tsx b/libs/react-components/src/experimental/table/table.spec.tsx index 4538b2006..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,14 +24,11 @@ describe("GoabxTable", () => { const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - // New event format with sorts array - const eventDetail = { sorts: [{ column: "name", direction: "asc" }] }; - // Legacy callback receives translated format - const expectedLegacy: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; + const detail: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; expect(table).toBeTruthy(); - table && fireEvent(table, new CustomEvent("_sort", { detail: eventDetail })); - expect(onSort).toHaveBeenCalledWith(expectedLegacy); + 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 e716472df..96299c3b7 100644 --- a/libs/react-components/src/experimental/table/table.tsx +++ b/libs/react-components/src/experimental/table/table.tsx @@ -1,7 +1,5 @@ import { GoabTableOnSortDetail, - GoabTableSortChangeDetail, - GoabTableSortEntry, GoabTableSortMode, GoabTableVariant, Margins, @@ -13,8 +11,7 @@ interface WCProps extends Margins { width?: string; stickyheader?: string; variant?: GoabTableVariant; - sortmode?: string; - initialsort?: string; + "sort-mode"?: GoabTableSortMode; testid?: string; striped?: string; version?: string; @@ -32,14 +29,8 @@ declare module "react" { /* eslint-disable-next-line */ export interface GoabxTableProps extends Margins { width?: string; - /** @deprecated Use onSortChange for new implementations */ onSort?: (detail: GoabTableOnSortDetail) => void; - /** Called when sort state changes. Receives array of current sorts. */ - onSortChange?: (sorts: GoabTableSortEntry[]) => void; - /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ sortMode?: GoabTableSortMode; - /** Initial sort configuration */ - initialSort?: GoabTableSortEntry[]; // stickyHeader?: boolean; TODO: enable this later variant?: GoabTableVariant; striped?: boolean; @@ -51,7 +42,7 @@ export interface GoabxTableProps extends Margins { // legacy name export type TableProps = GoabxTableProps; -export function GoabxTable({ onSort, onSortChange, sortMode, initialSort, version = "2", ...props }: GoabxTableProps) { +export function GoabxTable({ onSort, sortMode, version = "2", ...props }: GoabxTableProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) { @@ -59,28 +50,15 @@ export function GoabxTable({ onSort, onSortChange, sortMode, initialSort, versio } const current = ref.current; const sortListener = (e: unknown) => { - const detail = (e as CustomEvent).detail; - - // New API: onSortChange receives sorts array - if (onSortChange && "sorts" in detail) { - onSortChange(detail.sorts); - } - - // Legacy API: translate new format for backward compat - if (onSort && "sorts" in detail && detail.sorts.length > 0) { - const firstSort = detail.sorts[0]; - onSort({ - sortBy: firstSort.column, - sortDir: firstSort.direction === "asc" ? 1 : -1, - }); - } + const detail = (e as CustomEvent).detail; + onSort?.(detail); }; current.addEventListener("_sort", sortListener); return () => { current.removeEventListener("_sort", sortListener); }; - }, [ref, onSort, onSortChange]); + }, [ref, onSort]); return ( (rest, lowercase); + return ( - + {children} ); diff --git a/libs/react-components/src/lib/table/table.spec.tsx b/libs/react-components/src/lib/table/table.spec.tsx index 3556b1777..d9d38a2f7 100644 --- a/libs/react-components/src/lib/table/table.spec.tsx +++ b/libs/react-components/src/lib/table/table.spec.tsx @@ -25,14 +25,11 @@ describe("Table", () => { const { baseElement } = render(); const table = baseElement.querySelector("goa-table"); - // New event format with sorts array - const eventDetail = { sorts: [{ column: "name", direction: "asc" }] }; - // Legacy callback receives translated format - const expectedLegacy: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; + const event: GoabTableOnSortDetail = { sortBy: "name", sortDir: 1 }; expect(table).toBeTruthy(); - table && fireEvent(table, new CustomEvent("_sort", { detail: eventDetail })); - expect(onSort).toHaveBeenCalledWith(expectedLegacy); + table && fireEvent(table, new CustomEvent("_sort", { detail: event })); + expect(onSort).toHaveBeenCalledWith(event); }); it("should handle _sort event gracefully when no onSort prop is passed", () => { diff --git a/libs/react-components/src/lib/table/table.tsx b/libs/react-components/src/lib/table/table.tsx index cece4b667..659c6b6bc 100644 --- a/libs/react-components/src/lib/table/table.tsx +++ b/libs/react-components/src/lib/table/table.tsx @@ -1,26 +1,14 @@ import { GoabTableOnSortDetail, - GoabTableSortChangeDetail, - GoabTableSortEntry, - GoabTableSortMode, GoabTableVariant, Margins, } from "@abgov/ui-components-common"; import { ReactNode, useEffect, useRef } from "react"; -// Note: JSX.IntrinsicElements for "goa-table" is declared in experimental/table/table.tsx - /* eslint-disable-next-line */ export interface GoabTableProps extends Margins { width?: string; - /** @deprecated Use onSortChange for new implementations */ onSort?: (detail: GoabTableOnSortDetail) => void; - /** Called when sort state changes. Receives array of current sorts. */ - onSortChange?: (sorts: GoabTableSortEntry[]) => void; - /** Sort mode: "single" (default) or "multi" (up to 2 columns) */ - sortMode?: GoabTableSortMode; - /** Initial sort configuration */ - initialSort?: GoabTableSortEntry[]; // stickyHeader?: boolean; TODO: enable this later variant?: GoabTableVariant; testId?: string; @@ -30,7 +18,7 @@ export interface GoabTableProps extends Margins { // legacy name export type TableProps = GoabTableProps; -export function GoabTable({ onSort, onSortChange, sortMode, initialSort, ...props }: GoabTableProps) { +export function GoabTable({ onSort, ...props }: GoabTableProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) { @@ -38,28 +26,15 @@ export function GoabTable({ onSort, onSortChange, sortMode, initialSort, ...prop } const current = ref.current; const sortListener = (e: unknown) => { - const detail = (e as CustomEvent).detail; - - // New API: onSortChange receives sorts array - if (onSortChange && "sorts" in detail) { - onSortChange(detail.sorts); - } - - // Legacy API: translate new format for backward compat - if (onSort && "sorts" in detail && detail.sorts.length > 0) { - const firstSort = detail.sorts[0]; - onSort({ - sortBy: firstSort.column, - sortDir: firstSort.direction === "asc" ? 1 : -1, - }); - } + const detail = (e as CustomEvent).detail; + onSort?.(detail); }; current.addEventListener("_sort", sortListener); return () => { current.removeEventListener("_sort", sortListener); }; - }, [ref, onSort, onSortChange]); + }, [ref, onSort]); return ( + diff --git a/libs/web-components/src/components/table/TableSortHeader.svelte b/libs/web-components/src/components/table/TableSortHeader.svelte index b049d25fd..bb865e1a0 100644 --- a/libs/web-components/src/components/table/TableSortHeader.svelte +++ b/libs/web-components/src/components/table/TableSortHeader.svelte @@ -1,48 +1,70 @@ - + -