diff --git a/package.json b/package.json index def22f36d..2d03833df 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@babel/core": "^7.20.2", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", - "@gouvfr/dsfr": "1.14.2", + "@gouvfr/dsfr": "1.14.3", "@gouvfr/dsfr-chart": "^1.0.0", "@mui/icons-material": "^5.14.18", "@mui/material": "^5.14.18", diff --git a/patches/@gouvfr+dsfr+1.14.2.patch b/patches/@gouvfr+dsfr+1.14.3.patch similarity index 97% rename from patches/@gouvfr+dsfr+1.14.2.patch rename to patches/@gouvfr+dsfr+1.14.3.patch index 9f7764bef..e0e364cd4 100644 --- a/patches/@gouvfr+dsfr+1.14.2.patch +++ b/patches/@gouvfr+dsfr+1.14.3.patch @@ -8,7 +8,7 @@ index e4d335c..6c53dce 100644 + diff --git a/node_modules/@gouvfr/dsfr/dist/dsfr.module.js b/node_modules/@gouvfr/dsfr/dist/dsfr.module.js -index fcde523..7996e40 100644 +index b8903e9..0f63e71 100644 --- a/node_modules/@gouvfr/dsfr/dist/dsfr.module.js +++ b/node_modules/@gouvfr/dsfr/dist/dsfr.module.js @@ -6461,9 +6461,11 @@ class HeaderLinks extends api.core.Instance { diff --git a/src/Table.tsx b/src/Table.tsx index 7a67b7a84..4fd6a6968 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,13 +6,17 @@ import { cx } from "./tools/cx"; import { symToStr } from "tsafe/symToStr"; import type { FrClassName } from "./fr/generatedFromCss/classNames"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { createComponentI18nApi } from "./i18n"; export type TableProps = { id?: string; data: ReactNode[][]; className?: string; caption?: ReactNode; - headers?: ReactNode[]; + headers?: (ReactNode | TableProps.SortableColumn)[]; + onSort?: (column: number, order: TableProps.SortingOrder) => void; + defaultSort?: TableProps.SortingState; + sort?: TableProps.SortingState; /** Default: false */ fixed?: boolean; /** Default: false */ @@ -36,6 +40,18 @@ export namespace TableProps { : never; export type ColorVariant = ExtractColorVariant; + + export type SortingOrder = "ascending" | "descending" | "none"; + + export type SortingState = { + column: number; + order: SortingOrder; + }; + + export type SortableColumn = { + label: ReactNode; + sortable: boolean; + }; } /** @see */ @@ -45,6 +61,9 @@ export const Table = memo( id: id_props, data, headers, + onSort, + defaultSort, + sort, caption, bordered = false, noScroll = false, @@ -59,6 +78,8 @@ export const Table = memo( assert>(); + const { currentSort, cycleSortingOrder } = useSort(defaultSort, sort); + const id = useAnalyticsId({ "defaultIdPrefix": "fr-table", "explicitlyProvidedId": id_props @@ -84,34 +105,143 @@ export const Table = memo( className )} > - - {caption !== undefined && } - {headers !== undefined && ( - - - {headers.map((header, i) => ( - - ))} - - - )} - - {data.map((row, i) => ( - - {row.map((col, j) => ( - - ))} - - ))} - -
{caption}
- {header} -
{col}
+
+
+
+ + {caption !== undefined && } + {headers !== undefined && ( + + + {headers.map((header, i) => { + const label = isSortableColumn(header) + ? header.label + : header; + if (!isSortable(header)) { + return ( + + ); + } + const sortingOrder = + currentSort?.column === i + ? currentSort?.order + : "none"; + return ( + { + const newOrder = cycleSortingOrder(i); + onSort?.(i, newOrder); + }} + > + {label} + + ); + })} + + + )} + + {data.map((row, i) => ( + + {row.map((col, j) => ( + + ))} + + ))} + +
{caption}
+ {label} +
{col}
+
+
+
); }) ); +const SortableTh = ({ + children, + order, + onSort +}: { + children: ReactNode; + order: TableProps.SortingOrder; + onSort: () => void; +}) => { + const { t } = useTranslation(); + return ( + +
+ {children} + +
+ + ); +}; + +function useSort(defaultSort?: TableProps.SortingState, sort?: TableProps.SortingState) { + const [currentSortState, setCurrentSort] = useState( + defaultSort ?? null + ); + const currentSort = sort ?? currentSortState; + + function cycleSortingOrder(column: number): TableProps.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 }; +} + +function isSortable(header: ReactNode | TableProps.SortableColumn): boolean { + return isSortableColumn(header) && header.sortable; +} + +function isSortableColumn( + header: ReactNode | TableProps.SortableColumn +): header is TableProps.SortableColumn { + return typeof header === "object" && header != null && "sortable" in header; +} + Table.displayName = symToStr({ Table }); +const { useTranslation, addTableTranslations } = createComponentI18nApi({ + componentName: Table.displayName, + frMessages: { + "sort": "Trier" + } +}); + +addTableTranslations({ + lang: "en", + messages: { + "sort": "Sort" + } +}); + export default Table; diff --git a/stories/Table.stories.tsx b/stories/Table.stories.tsx index aaa936ee1..8825a988a 100644 --- a/stories/Table.stories.tsx +++ b/stories/Table.stories.tsx @@ -175,3 +175,51 @@ export const TableWithColorVariant = getStory({ ["Lorem ipsum d", "Lorem ipsu"] ] }); + +export const TableWithSortableColumns = getStory( + { + "caption": "Titre du tableau", + "headers": [ + "th0", + { label: "th1", sortable: true }, + { label: "th2", sortable: true }, + "th3" + ], + "onSort": (column, order) => window.alert(`sorting column "${column}" in "${order}" order`), + "defaultSort": { column: 2, order: "descending" }, + "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: ` +\`headers\` accepts either directly a label to render (\`th\`), or an object containing the label and +whether it is sortable (\`{ label: th, sortable: true }\`). + +A default sorting state can be provided with \`defaultSort\`, and the component can be entirely controlled with \`sort\` +and \`onSort\`.` + } +); diff --git a/yarn.lock b/yarn.lock index 241200b13..5aa18d094 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1436,10 +1436,10 @@ vue-custom-element "^3.2.14" vuex "^3.6.0" -"@gouvfr/dsfr@1.14.2": - version "1.14.2" - resolved "https://registry.yarnpkg.com/@gouvfr/dsfr/-/dsfr-1.14.2.tgz#cbd893eba6bb2151234ae484ac426f3c5ff16b19" - integrity sha512-EJ/ZZMLmSN4KISuuXp0X0lGf+gKVqYATyqgcM5IPhMQAkJ8s5pjzvthjv+Hu6zYk4jwf2NRVt+jQz7JRpw1yiA== +"@gouvfr/dsfr@1.14.3": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@gouvfr/dsfr/-/dsfr-1.14.3.tgz#41b3d4b52c279929eb8560c78fe417c57da834ef" + integrity sha512-UTplClQLp635J4vRHx/rJ7SKEi+bepc1FisvzTS8efQGQ3Gm8LttZf11NJ+4XhEMYLbCf8TBvFFwWBZ9ztRFkg== "@humanwhocodes/config-array@^0.5.0": version "0.5.0"