Skip to content

feat(table): enhance table #340

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
171 changes: 128 additions & 43 deletions src/Table.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +14,10 @@ export type TableProps = {
caption?: ReactNode;
headers?: ReactNode[];
/** Default: false */
headColumn?: boolean;
/** Default: false */
selectableRows?: boolean;
/** Default: false */
fixed?: boolean;
/** Default: false */
noScroll?: boolean;
Expand All @@ -23,19 +27,20 @@ export type TableProps = {
noCaption?: boolean;
/** Default: false */
bottomCaption?: boolean;
cellsAlignment?: (TableProps.Alignment | undefined)[][] | (TableProps.Alignment | undefined)[];
size?: TableProps.Size;
style?: CSSProperties;
colorVariant?: TableProps.ColorVariant;
};

export namespace TableProps {
type ExtractColorVariant<FrClassName> = FrClassName extends `fr-table--${infer AccentColor}`
? Exclude<
AccentColor,
"no-scroll" | "no-caption" | "caption-bottom" | "layout-fixed" | "bordered"
>
export type Size = "sm" | "md" | "lg";

type ExtractCellClasses<FrClassName> = FrClassName extends `fr-cell--${infer Alignment}`
? Alignment
: never;

export type ColorVariant = ExtractColorVariant<FrClassName>;
export type Alignment = ExtractCellClasses<FrClassName> &
("center" | "top" | "bottom" | "right");
}

/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/tableau> */
Expand All @@ -45,68 +50,148 @@ export const Table = memo(
id: id_props,
data,
headers,
headColumn = false,
selectableRows = false,
caption,
bordered = false,
noScroll = false,
fixed = false,
noCaption = false,
bottomCaption = false,
colorVariant,
size = "md",
cellsAlignment = undefined,
className,
style,
...rest
} = props;

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

const [checkedIds, setCheckedIds] = useState<number[]>([]);

const id = useAnalyticsId({
"defaultIdPrefix": "fr-table",
"explicitlyProvidedId": id_props
});

const getCellAlignment = (i: number, j: number): undefined | string => {
if (Array.isArray(cellsAlignment)) {
const rowCellsAlignement = cellsAlignment[i];
if (Array.isArray(rowCellsAlignement)) {
const cellAlignement = rowCellsAlignement[j];
return cellAlignement === undefined ? undefined : `fr-cell--${cellAlignement}`;
}

const cellAlignement = cellsAlignment[j];
return cellAlignement === undefined || Array.isArray(cellAlignement)
? undefined
: `fr-cell--${cellAlignement}`;
}
return undefined;
};

const getRole = (headColumn: boolean, i: number): React.AriaRole | undefined => {
return headColumn && i === 0 ? "rowheader" : undefined;
};

return (
<div
id={id}
ref={ref}
style={style}
className={cx(
fr.cx(
"fr-table",
{
"fr-table--bordered": bordered,
"fr-table--no-scroll": noScroll,
"fr-table--layout-fixed": fixed,
"fr-table--no-caption": noCaption,
"fr-table--caption-bottom": bottomCaption
},
colorVariant !== undefined && `fr-table--${colorVariant}`
),
fr.cx(size !== "md" && `fr-table--${size}`, "fr-table", {
"fr-table--bordered": bordered,
"fr-table--no-scroll": noScroll,
"fr-table--layout-fixed": fixed,
"fr-table--no-caption": noCaption,
"fr-table--caption-bottom": bottomCaption
}),
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-table__wrapper">
<div className="fr-table__container">
<div className="fr-table__content">
<table>
{caption !== undefined && <caption>{caption}</caption>}
{headers !== undefined && (
<thead>
<tr>
{headers.map((header, i) => (
<th
key={i}
scope="col"
role={getRole(headColumn, i)}
>
{header}
</th>
))}
</tr>
</thead>
)}
<tbody>
{data.map((row, i) => {
const isChecked = checkedIds.includes(i);
return (
<tr key={i} aria-selected={isChecked}>
{row.map((col, j) => {
const role = getRole(headColumn, j);
const HtmlElement =
role === undefined ? "td" : "th";
const isSelectable = selectableRows && j === 0;
if (isSelectable) {
return (
<HtmlElement
key={j}
className={cx(
getCellAlignment(i, j)
)}
role={role}
>
<div
className="fr-checkbox-group fr-checkbox-group--sm"
onClick={() => {
setCheckedIds(
isChecked
? checkedIds.filter(
id => id !== i
)
: [...checkedIds, i]
);
}}
>
<input
name="row-select"
type="checkbox"
checked={isChecked}
/>
<label className="fr-label">
{col}
</label>
</div>
</HtmlElement>
);
}

return (
<HtmlElement
key={j}
className={cx(getCellAlignment(i, j))}
role={role}
>
{col}
</HtmlElement>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})
Expand Down
126 changes: 93 additions & 33 deletions stories/Table.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Table, type TableProps } from "../dist/Table";
import { Table } from "../dist/Table";
import { getStoryFactory } from "./getStory";
import { sectionName } from "./sectionName";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import React from "react";

const { meta, getStory } = getStoryFactory({
sectionName,
Expand Down Expand Up @@ -37,34 +36,13 @@ const { meta, getStory } = getStoryFactory({
"description": "Move caption to bottom",
"type": { "name": "boolean" }
},
"colorVariant": {
"options": (() => {
const options = [
"green-tilleul-verveine",
"green-bourgeon",
"green-emeraude",
"green-menthe",
"green-archipel",
"blue-ecume",
"blue-cumulus",
"purple-glycine",
"pink-macaron",
"pink-tuile",
"brown-cafe-creme",
"brown-caramel",
"brown-opera",
"orange-terre-battue",
"yellow-moutarde",
"yellow-tournesol",
"beige-gris-galet",
undefined
] as const;

assert<Equals<typeof options[number], TableProps["colorVariant"]>>();

return options;
})(),
"control": { "type": "select", "labels": { "null": "no color variant" } }
"headColumn": {
"description": "Add a header column",
"type": { "name": "boolean" }
},
"selectableRows": {
"description": "Add a checkbox column",
"type": { "name": "boolean" }
}
}
});
Expand Down Expand Up @@ -158,8 +136,32 @@ export const TableWithBottomCaption = getStory({
]
});

export const TableWithColorVariant = getStory({
"colorVariant": "green-emeraude",
export const TableWithHeadColumn = getStory({
"headColumn": true,
"caption": "Titre du tableau",
"headers": ["", "titre"],
"data": [
["ligne 1", "Lorem ipsum dolor sit amet consectetur"],
["ligne 2", "Lorem ipsu"],
["ligne 3", "Lorem ipsum dolor sit amet consectetur"],
["ligne 4", "Lorem ipsu"]
]
});

export const SelectableRowsTableWithHeadColumn = getStory({
"headColumn": true,
"selectableRows": true,
"caption": "Titre du tableau",
"headers": ["", "titre"],
"data": [
["ligne 1", "Lorem ipsum dolor sit amet consectetur"],
["ligne 2", "Lorem ipsu"],
["ligne 3", "Lorem ipsum dolor sit amet consectetur"],
["ligne 4", "Lorem ipsu"]
]
});

export const SmallTable = getStory({
"caption": "Titre du tableau",
"headers": ["td", "titre"],
"data": [
Expand All @@ -173,5 +175,63 @@ export const TableWithColorVariant = getStory({
"Lorem ipsum dolor sit amet consectetur"
],
["Lorem ipsum d", "Lorem ipsu"]
],
"size": "sm"
});

export const LargeTable = getStory({
"caption": "Titre du tableau",
"headers": ["td", "titre"],
"data": [
[
"Lorem ipsum dolor sit amet consectetur adipisicin",
"Lorem ipsum dolor sit amet consectetur"
],
["Lorem ipsum d", "Lorem ipsu"],
[
"Lorem ipsum dolor sit amet consectetur adipisicin",
"Lorem ipsum dolor sit amet consectetur"
],
["Lorem ipsum d", "Lorem ipsu"]
],
"size": "lg"
});

const CellWithBr = (
<span>
Lorem <br />
ipsu
<br />d
</span>
);

export const TableWithSomeColumnAlignement = getStory({
"caption": "Titre du tableau",
"headers": ["aligné à droite", "aligné au centre", "aligné en haut", "aligné en bas"],
"data": [
[CellWithBr, "Lorem ipsum d", "Lorem ipsum d", "Lorem ipsum d"],
["Lorem ipsum d", CellWithBr, "Lorem ipsu", "Lorem ipsum d"],
["Lorem ipsum d", "Lorem ipsum d", CellWithBr, "Lorem ipsum d"],
["Lorem ipsum d", "Lorem ipsu", "Lorem ipsum d", CellWithBr]
],
"size": "lg",
"cellsAlignment": ["right", "center", "top", "bottom"]
});

export const TableWithSomeCellAlignement = getStory({
"caption": "Titre du tableau",
"headers": ["colonne 1", "colonne 2", "colonne 3", "colonne 4"],
"data": [
["aligné à droite", "Lorem ipsum d", "Lorem ipsum d", CellWithBr],
["Lorem ipsum d", "aligné au centre", "Lorem ipsum d", CellWithBr],
["Lorem ipsum d", "Lorem ipsum d", "aligné en haut", CellWithBr],
["Lorem ipsum d", "Lorem ipsu", CellWithBr, "aligné en bas"]
],
"size": "lg",
"cellsAlignment": [
["right", undefined, undefined, undefined],
[undefined, "center", undefined, undefined],
[undefined, undefined, "top", undefined],
[undefined, undefined, undefined, "bottom"]
]
});
Loading