Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ index e4d335c..6c53dce 100644
</svg>
+<!-- Slightly increasing size so it reaches the threshold that will prevent it from being inlined by Vite. -->
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 {
Expand Down
180 changes: 155 additions & 25 deletions src/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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";
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 */
Expand All @@ -36,6 +40,18 @@ export namespace TableProps {
: never;

export type ColorVariant = ExtractColorVariant<FrClassName>;

export type SortingOrder = "ascending" | "descending" | "none";

export type SortingState = {
column: number;
order: SortingOrder;
};

export type SortableColumn = {
label: ReactNode;
sortable: boolean;
};
}

/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/tableau> */
Expand All @@ -45,6 +61,9 @@ export const Table = memo(
id: id_props,
data,
headers,
onSort,
defaultSort,
sort,
caption,
bordered = false,
noScroll = false,
Expand All @@ -59,6 +78,8 @@ export const Table = memo(

assert<Equals<keyof typeof rest, never>>();

const { currentSort, cycleSortingOrder } = useSort(defaultSort, sort);

const id = useAnalyticsId({
"defaultIdPrefix": "fr-table",
"explicitlyProvidedId": id_props
Expand All @@ -84,34 +105,143 @@ export const Table = memo(
className
)}
>
<table>
{caption !== undefined && <caption>{caption}</caption>}
{headers !== undefined && (
<thead>
<tr>
{headers.map((header, i) => (
<th key={i} scope="col">
{header}
</th>
))}
</tr>
</thead>
)}
<tbody>
{data.map((row, i) => (
<tr key={i}>
{row.map((col, j) => (
<td key={j}>{col}</td>
))}
</tr>
))}
</tbody>
</table>
<div className={fr.cx("fr-table__wrapper")}>
<div className={fr.cx("fr-table__container")}>
<div className={fr.cx("fr-table__content")}>
<table>
{caption !== undefined && <caption>{caption}</caption>}
{headers !== undefined && (
<thead>
<tr>
{headers.map((header, i) => {
const label = isSortableColumn(header)
? header.label
: header;
if (!isSortable(header)) {
return (
<th key={i} scope="col">
{label}
</th>
);
}
const sortingOrder =
currentSort?.column === i
? currentSort?.order
: "none";
return (
<SortableTh
key={i}
order={sortingOrder}
onSort={() => {
const newOrder = cycleSortingOrder(i);
onSort?.(i, newOrder);
}}
>
{label}
</SortableTh>
);
})}
</tr>
</thead>
)}
<tbody>
{data.map((row, i) => (
<tr key={i}>
{row.map((col, j) => (
<td key={j}>{col}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})
);

const SortableTh = ({
children,
order,
onSort
}: {
children: ReactNode;
order: TableProps.SortingOrder;
onSort: () => void;
}) => {
const { t } = useTranslation();
return (
<th scope="col" aria-sort={order}>
<div className={fr.cx("fr-cell--sort")}>
{children}
<button
className={fr.cx(
"fr-btn",
"fr-btn--sm",
order === "none" && `fr-btn--sort`,
order === "ascending" && "fr-btn--sort-asc",
order === "descending" && "fr-btn--sort-desc"
)}
onClick={() => {
onSort();
}}
type="button"
>
{t("sort")}
</button>
</div>
</th>
);
};

function useSort(defaultSort?: TableProps.SortingState, sort?: TableProps.SortingState) {
const [currentSortState, setCurrentSort] = useState<TableProps.SortingState | null>(
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;
48 changes: 48 additions & 0 deletions stories/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (\`<span>th</span>\`), or an object containing the label and
whether it is sortable (\`{ label: <span>th</span>, sortable: true }\`).

A default sorting state can be provided with \`defaultSort\`, and the component can be entirely controlled with \`sort\`
and \`onSort\`.`
}
);
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down