+
+ 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 @@
-
+
-
@@ -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) }}
-
+
- Name
+ Name
- Department
+ Department
- Salary
+ Salary
-
+ @for (row of singleSorted; track row.id) {
+
{{ row.name }}
{{ row.department }}
{{ formatCurrency(row.salary) }}
+ }
-
+
@@ -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) }}
-
+
- Name
+ Name
- Department
+ Department
- Salary
+ Salary
-
+ @for (row of multiSorted; track row.id) {
+
{{ row.name }}
{{ row.department }}
{{ formatCurrency(row.salary) }}
+ }
-
+
-
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.
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).
@@ -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).
@@ -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.