Skip to content

Commit 44a85ea

Browse files
committed
feat(data-table): add sortAlways prop
Closes #2697
1 parent 3172dd1 commit 44a85ea

3 files changed

Lines changed: 211 additions & 5 deletions

File tree

src/DataTable/DataTable.svelte

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* @property {boolean} empty - Whether the header is empty
1010
* @property {(item: DataTableValue, row: Row) => DataTableValue} [display]
1111
* @property {false | ((a: DataTableValue, b: DataTableValue) => number)} [sort]
12+
* @property {boolean} [sortAlways] - Override table-level sortAlways for this column
1213
* @property {boolean} [columnMenu] - Whether the column menu is enabled
1314
* @property {string} [width]
1415
* @property {string} [minWidth]
@@ -17,6 +18,7 @@
1718
* @property {DataTableValue} value
1819
* @property {(item: DataTableValue, row: Row) => DataTableValue} [display]
1920
* @property {false | ((a: DataTableValue, b: DataTableValue) => number)} [sort]
21+
* @property {boolean} [sortAlways] - Override table-level sortAlways for this column
2022
* @property {boolean} [columnMenu] - Whether the column menu is enabled
2123
* @property {string} [width]
2224
* @property {string} [minWidth]
@@ -141,6 +143,12 @@
141143
*/
142144
export let sortDirection = "none";
143145
146+
/**
147+
* Set to `true` to only toggle between "ascending" and
148+
* "descending" sort directions, skipping "none".
149+
*/
150+
export let sortAlways = false;
151+
144152
/**
145153
* Set to `true` for the expandable variant.
146154
* Automatically set to `true` if `batchExpansion` is `true`.
@@ -256,11 +264,6 @@
256264
import TableHeader from "./TableHeader.svelte";
257265
import TableRow from "./TableRow.svelte";
258266
259-
const sortDirectionMap = {
260-
none: "ascending",
261-
ascending: "descending",
262-
descending: "none",
263-
};
264267
const dispatch = createEventDispatcher();
265268
/**
266269
* @type {import("svelte/store").Writable<ReadonlyArray<Row["id"]>>}
@@ -682,6 +685,19 @@
682685
} else {
683686
let currentSortDirection =
684687
sortKey === header.key ? sortDirection : "none";
688+
const effectiveSortAlways =
689+
header.sortAlways ?? sortAlways;
690+
const sortDirectionMap = effectiveSortAlways
691+
? {
692+
none: "ascending",
693+
ascending: "descending",
694+
descending: "ascending",
695+
}
696+
: {
697+
none: "ascending",
698+
ascending: "descending",
699+
descending: "none",
700+
};
685701
sortDirection = sortDirectionMap[currentSortDirection];
686702
sortKey =
687703
sortDirection === "none" ? null : thKeys[header.key];

tests/DataTable/DataTable.test.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
a: string | number | boolean,
1919
b: string | number | boolean,
2020
) => number);
21+
sortAlways?: boolean;
2122
};
2223
2324
export let headers: readonly DataTableHeader[] = [
@@ -57,6 +58,7 @@
5758
undefined;
5859
export let zebra = false;
5960
export let sortable = false;
61+
export let sortAlways = false;
6062
export let stickyHeader = false;
6163
export let useStaticWidth = false;
6264
export let expandable = false;
@@ -105,6 +107,7 @@
105107
{size}
106108
{zebra}
107109
{sortable}
110+
{sortAlways}
108111
{stickyHeader}
109112
{useStaticWidth}
110113
{expandable}

tests/DataTable/DataTable.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,193 @@ describe("DataTable", () => {
414414
expect(sortedCells[2]).toHaveTextContent("20");
415415
});
416416

417+
it("sortAlways: first click goes to ascending", async () => {
418+
render(DataTable, {
419+
props: {
420+
sortable: true,
421+
sortAlways: true,
422+
headers,
423+
rows,
424+
},
425+
});
426+
427+
const nameHeader = screen.getByText("Name");
428+
await user.click(nameHeader);
429+
430+
const tableRows = screen
431+
.getAllByRole("row")
432+
.filter((row) => row.closest("tbody") !== null);
433+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
434+
"Load Balancer 1",
435+
);
436+
});
437+
438+
it("sortAlways: toggles between ascending and descending only", async () => {
439+
render(DataTable, {
440+
props: {
441+
sortable: true,
442+
sortAlways: true,
443+
headers,
444+
rows,
445+
},
446+
});
447+
448+
const nameHeader = screen.getByText("Name");
449+
450+
// Click 1: none -> ascending
451+
await user.click(nameHeader);
452+
let tableRows = screen
453+
.getAllByRole("row")
454+
.filter((row) => row.closest("tbody") !== null);
455+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
456+
"Load Balancer 1",
457+
);
458+
459+
// Click 2: ascending -> descending
460+
await user.click(nameHeader);
461+
tableRows = screen
462+
.getAllByRole("row")
463+
.filter((row) => row.closest("tbody") !== null);
464+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
465+
"Load Balancer 3",
466+
);
467+
468+
// Click 3: descending -> ascending (NOT back to none)
469+
await user.click(nameHeader);
470+
tableRows = screen
471+
.getAllByRole("row")
472+
.filter((row) => row.closest("tbody") !== null);
473+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
474+
"Load Balancer 1",
475+
);
476+
});
477+
478+
it("sortAlways: switches columns without resetting to none", async () => {
479+
render(DataTable, {
480+
props: {
481+
sortable: true,
482+
sortAlways: true,
483+
headers,
484+
rows,
485+
},
486+
});
487+
488+
const nameHeader = screen.getByText("Name");
489+
const portHeader = screen.getByText("Port");
490+
491+
// Sort by name ascending
492+
await user.click(nameHeader);
493+
let tableRows = screen
494+
.getAllByRole("row")
495+
.filter((row) => row.closest("tbody") !== null);
496+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
497+
"Load Balancer 1",
498+
);
499+
500+
// Switch to port – should sort ascending
501+
await user.click(portHeader);
502+
tableRows = screen
503+
.getAllByRole("row")
504+
.filter((row) => row.closest("tbody") !== null);
505+
expect(within(tableRows[0]).getAllByRole("cell")[2]).toHaveTextContent(
506+
"80",
507+
);
508+
});
509+
510+
it("without sortAlways: third click resets to original order", async () => {
511+
render(DataTable, {
512+
props: {
513+
sortable: true,
514+
headers,
515+
rows,
516+
},
517+
});
518+
519+
const nameHeader = screen.getByText("Name");
520+
521+
// Click 1: ascending
522+
await user.click(nameHeader);
523+
// Click 2: descending
524+
await user.click(nameHeader);
525+
// Click 3: none (original order)
526+
await user.click(nameHeader);
527+
528+
const tableRows = screen
529+
.getAllByRole("row")
530+
.filter((row) => row.closest("tbody") !== null);
531+
// Original order: Load Balancer 3, Load Balancer 1, Load Balancer 2
532+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
533+
"Load Balancer 3",
534+
);
535+
});
536+
537+
it("header.sortAlways overrides table: column with sortAlways: true stays sorted", async () => {
538+
render(DataTable, {
539+
props: {
540+
sortable: true,
541+
headers: [
542+
{ key: "name", value: "Name", sortAlways: true },
543+
{ key: "protocol", value: "Protocol" },
544+
{ key: "port", value: "Port", sortAlways: false },
545+
],
546+
rows,
547+
},
548+
});
549+
550+
const nameHeader = screen.getByText("Name");
551+
const portHeader = screen.getByText("Port");
552+
553+
// Name has sortAlways: true (override) – third click stays sorted
554+
await user.click(nameHeader);
555+
await user.click(nameHeader);
556+
await user.click(nameHeader);
557+
let tableRows = screen
558+
.getAllByRole("row")
559+
.filter((row) => row.closest("tbody") !== null);
560+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
561+
"Load Balancer 1",
562+
);
563+
564+
// Port has sortAlways: false (override) – third click unsorts
565+
await user.click(portHeader);
566+
await user.click(portHeader);
567+
await user.click(portHeader);
568+
tableRows = screen
569+
.getAllByRole("row")
570+
.filter((row) => row.closest("tbody") !== null);
571+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
572+
"Load Balancer 3",
573+
);
574+
});
575+
576+
it("header.sortAlways overrides table: column with sortAlways: false allows unsort when table has sortAlways: true", async () => {
577+
render(DataTable, {
578+
props: {
579+
sortable: true,
580+
sortAlways: true,
581+
headers: [
582+
{ key: "name", value: "Name" },
583+
{ key: "port", value: "Port", sortAlways: false },
584+
],
585+
rows,
586+
},
587+
});
588+
589+
const portHeader = screen.getByText("Port");
590+
591+
// Port has sortAlways: false – overrides table, third click unsorts
592+
await user.click(portHeader);
593+
await user.click(portHeader);
594+
await user.click(portHeader);
595+
596+
const tableRows = screen
597+
.getAllByRole("row")
598+
.filter((row) => row.closest("tbody") !== null);
599+
expect(within(tableRows[0]).getAllByRole("cell")[0]).toHaveTextContent(
600+
"Load Balancer 3",
601+
);
602+
});
603+
417604
// Selection tests
418605
it("handles selectable rows", async () => {
419606
const { container } = render(DataTable, {

0 commit comments

Comments
 (0)