From 93a2eb687710816706423d00d0caaf11719e947a Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Thu, 27 Aug 2026 18:17:35 +0200 Subject: [PATCH 01/11] feat(Table): Add sorting to the Table component --- src/Table.tsx | 85 +++++++++++++++++++++++++++++++++++---- stories/Table.stories.tsx | 43 ++++++++++++++++++++ 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index 7a67b7a84..4cca6084a 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, type ReactNode, type CSSProperties } from "react"; +import React, { forwardRef, memo, type ReactNode, type CSSProperties, useState } from "react"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { fr } from "./fr"; @@ -6,6 +6,7 @@ import { cx } from "./tools/cx"; import { symToStr } from "tsafe/symToStr"; import type { FrClassName } from "./fr/generatedFromCss/classNames"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import SortingOrder = TableProps.SortingOrder; export type TableProps = { id?: string; @@ -13,6 +14,9 @@ export type TableProps = { className?: string; caption?: ReactNode; headers?: ReactNode[]; + /** Default: [] */ + sortableColumns?: (boolean | undefined)[]; + onSort?: (column: number, order: SortingOrder) => void; /** Default: false */ fixed?: boolean; /** Default: false */ @@ -36,6 +40,8 @@ export namespace TableProps { : never; export type ColorVariant = ExtractColorVariant; + + export type SortingOrder = "ascending" | "descending" | "none"; } /** @see */ @@ -45,6 +51,8 @@ export const Table = memo( id: id_props, data, headers, + sortableColumns = [], + onSort, caption, bordered = false, noScroll = false, @@ -59,6 +67,24 @@ export const Table = memo( assert>(); + const [currentSort, setCurrentSort] = useState<{ + column: number; + order: SortingOrder; + } | null>(null); + + function cycleSortingOrder(column: number): SortingOrder { + if (currentSort?.column !== column || currentSort?.order === "none") { + setCurrentSort({ column, order: "ascending" }); + return "ascending"; + } + if (currentSort?.order === "ascending") { + setCurrentSort({ column, order: "descending" }); + return "descending"; + } + setCurrentSort(null); + return "none"; + } + const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", "explicitlyProvidedId": id_props @@ -84,16 +110,35 @@ export const Table = memo( className )} > - +
{caption !== undefined && } {headers !== undefined && ( - {headers.map((header, i) => ( - - ))} + {headers.map((header, i) => { + const sortable = sortableColumns[i]; + if (sortable) { + const sortingOrder = + currentSort?.column === i ? currentSort?.order : "none"; + return ( + { + const newOrder = cycleSortingOrder(i); + onSort?.(i, newOrder); + }} + > + {header} + + ); + } + return ( + + ); + })} )} @@ -112,6 +157,32 @@ export const Table = memo( }) ); +const SortableTh = ({ + children, + order, + onSort +}: { + children: React.ReactNode; + order: TableProps.SortingOrder; + onSort: () => void; +}) => ( + +); + Table.displayName = symToStr({ Table }); export default Table; diff --git a/stories/Table.stories.tsx b/stories/Table.stories.tsx index aaa936ee1..d29c54cb7 100644 --- a/stories/Table.stories.tsx +++ b/stories/Table.stories.tsx @@ -175,3 +175,46 @@ export const TableWithColorVariant = getStory({ ["Lorem ipsum d", "Lorem ipsu"] ] }); + +export const TableWithSortableColumns = getStory( + { + "caption": "Titre du tableau", + "headers": ["th0", "th1", "th2", "th3"], + "sortableColumns": [undefined, true, true, false], + "onSort": (column, order) => window.alert(`sorting column "${column}" in "${order}" order`), + "data": [ + [ + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame" + ], + [ + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame" + ], + [ + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame" + ], + [ + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame", + "Lorem ipsum dolor sit ame" + ] + ] + }, + { + description: ` +\`sortableColumns\` accepts an array containing any combination of booleans and \`undefined\` : +\`[true, , false, undefined, true]\` will make columns 1 and 5 sortable. + +If \`sortableColumns\` is longer that the number of columns, the remaining values are ignored. +If it is shorter, the remaining columns will not be sortable.` + } +); From d89aef442df3697847051d81997efb8711298955 Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Thu, 27 Aug 2026 18:27:23 +0200 Subject: [PATCH 02/11] refactor(Table): Extract sorting handling in hook --- src/Table.tsx | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index 4cca6084a..58c1336e0 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -67,23 +67,7 @@ export const Table = memo( assert>(); - const [currentSort, setCurrentSort] = useState<{ - column: number; - order: SortingOrder; - } | null>(null); - - function cycleSortingOrder(column: number): SortingOrder { - if (currentSort?.column !== column || currentSort?.order === "none") { - setCurrentSort({ column, order: "ascending" }); - return "ascending"; - } - if (currentSort?.order === "ascending") { - setCurrentSort({ column, order: "descending" }); - return "descending"; - } - setCurrentSort(null); - return "none"; - } + const { currentSort, cycleSortingOrder } = useSort(); const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", @@ -183,6 +167,28 @@ const SortableTh = ({ ); +function useSort() { + const [currentSort, setCurrentSort] = useState<{ + column: number; + order: SortingOrder; + } | null>(null); + + function cycleSortingOrder(column: number): SortingOrder { + if (currentSort?.column !== column || currentSort?.order === "none") { + setCurrentSort({ column, order: "ascending" }); + return "ascending"; + } + if (currentSort?.order === "ascending") { + setCurrentSort({ column, order: "descending" }); + return "descending"; + } + setCurrentSort(null); + return "none"; + } + + return { currentSort, cycleSortingOrder }; +} + Table.displayName = symToStr({ Table }); export default Table; From 9027d02f00863950434cf31acd6545e330d9c307 Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Thu, 27 Aug 2026 18:52:23 +0200 Subject: [PATCH 03/11] feat(Table): Add default sorting state --- src/Table.tsx | 17 +++++++++++------ stories/Table.stories.tsx | 5 ++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index 58c1336e0..e3d6bec55 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -7,6 +7,7 @@ import { symToStr } from "tsafe/symToStr"; import type { FrClassName } from "./fr/generatedFromCss/classNames"; import { useAnalyticsId } from "./tools/useAnalyticsId"; import SortingOrder = TableProps.SortingOrder; +import SortingState = TableProps.SortingState; export type TableProps = { id?: string; @@ -17,6 +18,7 @@ export type TableProps = { /** Default: [] */ sortableColumns?: (boolean | undefined)[]; onSort?: (column: number, order: SortingOrder) => void; + defaultSort?: SortingState; /** Default: false */ fixed?: boolean; /** Default: false */ @@ -42,6 +44,11 @@ export namespace TableProps { export type ColorVariant = ExtractColorVariant; export type SortingOrder = "ascending" | "descending" | "none"; + + export type SortingState = { + column: number; + order: SortingOrder; + }; } /** @see */ @@ -53,6 +60,7 @@ export const Table = memo( headers, sortableColumns = [], onSort, + defaultSort, caption, bordered = false, noScroll = false, @@ -67,7 +75,7 @@ export const Table = memo( assert>(); - const { currentSort, cycleSortingOrder } = useSort(); + const { currentSort, cycleSortingOrder } = useSort(defaultSort); const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", @@ -167,11 +175,8 @@ const SortableTh = ({ ); -function useSort() { - const [currentSort, setCurrentSort] = useState<{ - column: number; - order: SortingOrder; - } | null>(null); +function useSort(defaultSort?: SortingState) { + const [currentSort, setCurrentSort] = useState(defaultSort ?? null); function cycleSortingOrder(column: number): SortingOrder { if (currentSort?.column !== column || currentSort?.order === "none") { diff --git a/stories/Table.stories.tsx b/stories/Table.stories.tsx index d29c54cb7..7a91d11a2 100644 --- a/stories/Table.stories.tsx +++ b/stories/Table.stories.tsx @@ -182,6 +182,7 @@ export const TableWithSortableColumns = getStory( "headers": ["th0", "th1", "th2", "th3"], "sortableColumns": [undefined, true, true, false], "onSort": (column, order) => window.alert(`sorting column "${column}" in "${order}" order`), + "defaultSort": { column: 2, order: "descending" }, "data": [ [ "Lorem ipsum dolor sit ame", @@ -215,6 +216,8 @@ export const TableWithSortableColumns = getStory( \`[true, , false, undefined, true]\` will make columns 1 and 5 sortable. If \`sortableColumns\` is longer that the number of columns, the remaining values are ignored. -If it is shorter, the remaining columns will not be sortable.` +If it is shorter, the remaining columns will not be sortable. + +A default sorting state can be provided with \`defaultSort\`.` } ); From d31e7cf0e5bcad43ea093b1dafc232b42724b78a Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Thu, 27 Aug 2026 19:43:19 +0200 Subject: [PATCH 04/11] feat(Table): Add props to control table sorting --- src/Table.tsx | 5 ++++- stories/Table.stories.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index e3d6bec55..2959b90e9 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -19,6 +19,7 @@ export type TableProps = { sortableColumns?: (boolean | undefined)[]; onSort?: (column: number, order: SortingOrder) => void; defaultSort?: SortingState; + sort?: SortingState; /** Default: false */ fixed?: boolean; /** Default: false */ @@ -61,6 +62,7 @@ export const Table = memo( sortableColumns = [], onSort, defaultSort, + sort, caption, bordered = false, noScroll = false, @@ -75,7 +77,8 @@ export const Table = memo( assert>(); - const { currentSort, cycleSortingOrder } = useSort(defaultSort); + const { currentSort: currentSortState, cycleSortingOrder } = useSort(defaultSort); + const currentSort = sort ?? currentSortState; const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", diff --git a/stories/Table.stories.tsx b/stories/Table.stories.tsx index 7a91d11a2..d84ef7014 100644 --- a/stories/Table.stories.tsx +++ b/stories/Table.stories.tsx @@ -218,6 +218,7 @@ export const TableWithSortableColumns = getStory( If \`sortableColumns\` is longer that the number of columns, the remaining values are ignored. If it is shorter, the remaining columns will not be sortable. -A default sorting state can be provided with \`defaultSort\`.` +A default sorting state can be provided with \`defaultSort\`, and the component can be entirely controlled with \`sort\` +and \`onSort\`.` } ); From 4739d1e06aaa424f8fad92dd3656ca1748f6c9cd Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Thu, 27 Aug 2026 20:21:21 +0200 Subject: [PATCH 05/11] refactor(Table): Flip if-else to simplify nesting --- src/Table.tsx | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index 2959b90e9..31d381801 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -112,26 +112,26 @@ export const Table = memo( {headers.map((header, i) => { const sortable = sortableColumns[i]; - if (sortable) { - const sortingOrder = - currentSort?.column === i ? currentSort?.order : "none"; + if (!sortable) { return ( - { - const newOrder = cycleSortingOrder(i); - onSort?.(i, newOrder); - }} - > + ); } + const sortingOrder = + currentSort?.column === i ? currentSort?.order : "none"; return ( - + ); })} From 7a1e7d41c97e9f141d38906515c8a21b96edaede Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Mon, 31 Aug 2026 14:22:52 +0200 Subject: [PATCH 06/11] fix(Table): Fix sorting ignoring controlled state in onSort --- src/Table.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index 31d381801..ccaf73956 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -77,8 +77,7 @@ export const Table = memo( assert>(); - const { currentSort: currentSortState, cycleSortingOrder } = useSort(defaultSort); - const currentSort = sort ?? currentSortState; + const { currentSort, cycleSortingOrder } = useSort(defaultSort, sort); const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", @@ -178,8 +177,9 @@ const SortableTh = ({ ); -function useSort(defaultSort?: SortingState) { - const [currentSort, setCurrentSort] = useState(defaultSort ?? null); +function useSort(defaultSort?: SortingState, sort?: SortingState) { + const [currentSortState, setCurrentSort] = useState(defaultSort ?? null); + const currentSort = sort ?? currentSortState; function cycleSortingOrder(column: number): SortingOrder { if (currentSort?.column !== column || currentSort?.order === "none") { From ec8d497224bbf408012e0f58ede391953cb86493 Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Mon, 31 Aug 2026 14:37:22 +0200 Subject: [PATCH 07/11] fix(Table): Fix missing name and type on sort button --- src/Table.tsx | 55 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index ccaf73956..b01b71a5e 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -8,6 +8,7 @@ import type { FrClassName } from "./fr/generatedFromCss/classNames"; import { useAnalyticsId } from "./tools/useAnalyticsId"; import SortingOrder = TableProps.SortingOrder; import SortingState = TableProps.SortingState; +import { createComponentI18nApi } from "./i18n"; export type TableProps = { id?: string; @@ -159,23 +160,29 @@ const SortableTh = ({ children: React.ReactNode; order: TableProps.SortingOrder; onSort: () => void; -}) => ( - -); +}) => { + const { t } = useTranslation(); + return ( + + ); +}; function useSort(defaultSort?: SortingState, sort?: SortingState) { const [currentSortState, setCurrentSort] = useState(defaultSort ?? null); @@ -199,4 +206,18 @@ function useSort(defaultSort?: SortingState, sort?: SortingState) { Table.displayName = symToStr({ Table }); +const { useTranslation, addTableTranslations } = createComponentI18nApi({ + componentName: Table.displayName, + frMessages: { + "sort": "Trier" + } +}); + +addTableTranslations({ + lang: "en", + messages: { + "sort": "Sort" + } +}); + export default Table; From 25e41f372266ad06f024f7636fe0163e60e22fa8 Mon Sep 17 00:00:00 2001 From: Gauthier Fiorentino Date: Mon, 31 Aug 2026 15:26:39 +0200 Subject: [PATCH 08/11] fix(Table): Fix expected structure of CSS wrappers --- src/Table.tsx | 96 ++++++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/Table.tsx b/src/Table.tsx index b01b71a5e..fbc91de35 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -105,48 +105,56 @@ export const Table = memo( className )} > -
{caption}
- {header} - + {header} +
+
+ {children} +
+
{header} - + + { + const newOrder = cycleSortingOrder(i); + onSort?.(i, newOrder); + }} + > {header} -
-
- {children} -
-
+
+ {children} + +
+
- {caption !== undefined && } - {headers !== undefined && ( - - - {headers.map((header, i) => { - const sortable = sortableColumns[i]; - if (!sortable) { - return ( - - ); - } - const sortingOrder = - currentSort?.column === i ? currentSort?.order : "none"; - return ( - { - const newOrder = cycleSortingOrder(i); - onSort?.(i, newOrder); - }} - > - {header} - - ); - })} - - - )} - - {data.map((row, i) => ( - - {row.map((col, j) => ( - - ))} - - ))} - -
{caption}
- {header} -
{col}
+
+
+
+ + {caption !== undefined && } + {headers !== undefined && ( + + + {headers.map((header, i) => { + const sortable = sortableColumns[i]; + if (!sortable) { + return ( + + ); + } + const sortingOrder = + currentSort?.column === i + ? currentSort?.order + : "none"; + return ( + { + const newOrder = cycleSortingOrder(i); + onSort?.(i, newOrder); + }} + > + {header} + + ); + })} + + + )} + + {data.map((row, i) => ( + + {row.map((col, j) => ( + + ))} + + ))} + +
{caption}
+ {header} +
{col}
+
+
+
); }) @@ -164,10 +172,10 @@ const SortableTh = ({ const { t } = useTranslation(); return ( -
+
{children}