Skip to content

Commit b7c4aab

Browse files
committed
Add Tabs component
1 parent 0a74447 commit b7c4aab

File tree

11 files changed

+682
-14
lines changed

11 files changed

+682
-14
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
}
6969
},
7070
"dependencies": {
71+
"moize": "^6.1.3",
7172
"tsafe": "^1.1.1"
7273
},
7374
"devDependencies": {

src/Alert.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ export const Alert = memo(
143143
classes.root,
144144
className
145145
)}
146-
ref={ref}
147146
{...(refShouldSetRole.current && { "role": "alert" })}
147+
ref={ref}
148148
{...rest}
149149
>
150150
<HtmlTitleTag className={cx(fr.cx("fr-alert__title"), classes.title)}>

src/Tabs.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { memo, forwardRef, useId, useState, useEffect } from "react";
2+
import type { ReactNode } from "react";
3+
import type { FrIconClassName, RiIconClassName } from "./lib/generatedFromCss/classNames";
4+
import { symToStr } from "tsafe/symToStr";
5+
import { fr } from "./lib";
6+
import { cx } from "./lib/tools/cx";
7+
import { assert } from "tsafe/assert";
8+
import type { Equals } from "tsafe";
9+
import { useCallbackFactory } from "./lib/tools/powerhooks/useCallbackFactory";
10+
import "@gouvfr/dsfr/dist/component/tab/tab.css";
11+
12+
export type TabsProps = TabsProps.Uncontrolled | TabsProps.Controlled;
13+
14+
export namespace TabsProps {
15+
export type Common = {
16+
className?: string;
17+
label?: string;
18+
classes?: Partial<Record<"root" | "tab" | "panel", string>>;
19+
};
20+
21+
export type Uncontrolled = Common & {
22+
tabs: {
23+
label: ReactNode;
24+
iconId?: FrIconClassName | RiIconClassName;
25+
content: ReactNode;
26+
}[];
27+
selectedTabId?: undefined;
28+
onTabChange?: (params: { tabIndex: number; tab: Uncontrolled["tabs"][number] }) => void;
29+
children?: undefined;
30+
};
31+
32+
export type Controlled = Common & {
33+
tabs: {
34+
tabId: string;
35+
label: ReactNode;
36+
iconId?: FrIconClassName | RiIconClassName;
37+
}[];
38+
selectedTabId: string;
39+
onTabChange: (tabId: string) => void;
40+
children?: NonNullable<ReactNode>;
41+
};
42+
}
43+
44+
/** @see <https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/onglets> */
45+
export const Tabs = memo(
46+
forwardRef<HTMLDivElement, TabsProps>((props, ref) => {
47+
const {
48+
className,
49+
label,
50+
classes = {},
51+
tabs,
52+
selectedTabId,
53+
onTabChange,
54+
children,
55+
...rest
56+
} = props;
57+
58+
assert<Equals<keyof typeof rest, never>>();
59+
60+
const id = useId();
61+
62+
const getSelectedTabIndex = () => {
63+
assert(selectedTabId !== undefined);
64+
return tabs.findIndex(({ tabId }) => tabId === selectedTabId);
65+
};
66+
67+
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(
68+
selectedTabId !== undefined ? getSelectedTabIndex : 0
69+
);
70+
71+
useEffect(() => {
72+
if (selectedTabId === undefined) {
73+
return;
74+
}
75+
76+
setSelectedTabIndex(getSelectedTabIndex());
77+
}, [selectedTabId]);
78+
79+
const onTabClickFactory = useCallbackFactory(([tabIndex]: [number]) => {
80+
if (selectedTabId === undefined) {
81+
onTabChange?.({
82+
tabIndex,
83+
"tab": tabs[tabIndex]
84+
});
85+
} else {
86+
onTabChange(tabs[tabIndex].tabId);
87+
}
88+
});
89+
90+
const getPanelId = (tabIndex: number) => `tabpanel-${id}-${tabIndex}-panel`;
91+
const getTabId = (tabIndex: number) => `tabpanel-${id}-${tabIndex}`;
92+
93+
return (
94+
<div className={cx(fr.cx("fr-tabs"), className)} ref={ref} {...rest}>
95+
<ul className={fr.cx("fr-tabs__list")} role="tablist" aria-label={label}>
96+
{tabs.map(({ label, iconId }, tabIndex) => (
97+
<li key={label + (iconId ?? "")} role="presentation">
98+
<button
99+
id={getTabId(tabIndex)}
100+
className={cx(
101+
fr.cx("fr-tabs__tab", iconId, "fr-tabs__tab--icon-left"),
102+
classes.tab
103+
)}
104+
tabIndex={tabIndex === selectedTabIndex ? 0 : -1}
105+
role="tab"
106+
aria-selected={tabIndex === selectedTabIndex}
107+
aria-controls={getPanelId(tabIndex)}
108+
onClick={onTabClickFactory(tabIndex)}
109+
>
110+
{label}
111+
</button>
112+
</li>
113+
))}
114+
</ul>
115+
{selectedTabId === undefined ? (
116+
tabs.map(({ content }, tabIndex) => (
117+
<div
118+
id={getPanelId(tabIndex)}
119+
className={cx(
120+
fr.cx(
121+
"fr-tabs__panel",
122+
`fr-tabs__panel${
123+
tabIndex === selectedTabIndex ? "--selected" : ""
124+
}`
125+
),
126+
classes.panel
127+
)}
128+
role="tabpanel"
129+
aria-labelledby={getTabId(tabIndex)}
130+
tabIndex={0}
131+
>
132+
{content}
133+
</div>
134+
))
135+
) : (
136+
<div
137+
id={getPanelId(selectedTabIndex)}
138+
className={cx(
139+
fr.cx("fr-tabs__panel", "fr-tabs__panel--selected"),
140+
classes.panel
141+
)}
142+
role="tabpanel"
143+
aria-labelledby={getTabId(selectedTabIndex)}
144+
tabIndex={0}
145+
>
146+
{children}
147+
</div>
148+
)}
149+
</div>
150+
);
151+
})
152+
);
153+
154+
Tabs.displayName = symToStr({ Tabs });
155+
156+
export default Tabs;

src/lib/tools/memoize.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export function memoize<F extends (...args: (string | number | boolean)[]) => any>(
2+
fn: F,
3+
options: {
4+
argsLength: number;
5+
}
6+
): F {
7+
const cache: Record<string, ReturnType<F>> = {};
8+
9+
const { argsLength } = options;
10+
11+
return ((...args: Parameters<F>) => {
12+
const key = JSON.stringify(args.slice(0, argsLength).join("-sIs9sAslOdeWlEdIos3-"));
13+
14+
console.log(key, JSON.stringify({ argsLength, args }));
15+
16+
if (key in cache) {
17+
return cache[key];
18+
}
19+
20+
return (cache[key] = fn(...args));
21+
}) as any;
22+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useRef, useState } from "react";
2+
import { id } from "tsafe/id";
3+
import { memoize } from "../memoize";
4+
5+
export type CallbackFactory<FactoryArgs extends unknown[], Args extends unknown[], R> = (
6+
...factoryArgs: FactoryArgs
7+
) => (...args: Args) => R;
8+
9+
/**
10+
* https://docs.powerhooks.dev/api-reference/usecallbackfactory
11+
*
12+
* const callbackFactory= useCallbackFactory(
13+
* ([key]: [string], [params]: [{ foo: number; }]) => {
14+
* ...
15+
* },
16+
* []
17+
* );
18+
*
19+
* WARNING: Factory args should not be of variable length.
20+
*
21+
*/
22+
export function useCallbackFactory<
23+
FactoryArgs extends (string | number | boolean)[],
24+
Args extends unknown[],
25+
R = void
26+
>(callback: (...callbackArgs: [FactoryArgs, Args]) => R): CallbackFactory<FactoryArgs, Args, R> {
27+
type Out = CallbackFactory<FactoryArgs, Args, R>;
28+
29+
const callbackRef = useRef<typeof callback>(callback);
30+
31+
callbackRef.current = callback;
32+
33+
const memoizedRef = useRef<Out | undefined>(undefined);
34+
35+
return useState(() =>
36+
id<Out>((...factoryArgs) => {
37+
if (memoizedRef.current === undefined) {
38+
memoizedRef.current = memoize(
39+
((...factoryArgs: FactoryArgs) =>
40+
(...args: Args) =>
41+
callbackRef.current(factoryArgs, args)) as any,
42+
{ "argsLength": factoryArgs.length }
43+
);
44+
}
45+
46+
return (memoizedRef.current as any)(...factoryArgs);
47+
})
48+
)[0];
49+
}

test/integration/next/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
"dependencies": {
1515
"next": "^13.0.1",
1616
"react": "18.2.0",
17-
"react-dom": "18.2.0",
17+
"react-dom": "18.2.0",
1818
"next-transpile-modules": "^10.0.0",
19+
"@emotion/react": "^11.10.5",
20+
"@emotion/server": "^11.10.0",
21+
"tss-react": "^4.4.4",
1922
"@codegouvfr/react-dsfr": "file:../../../dist"
2023
},
2124
"devDependencies": {

test/integration/next/pages/_app.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AppProps } from "next/app";
22
import { DsfrLangProvider } from "@codegouvfr/react-dsfr";
33
import { createNextDsfrIntegrationApi } from "@codegouvfr/react-dsfr/next";
44
import { Header } from "@codegouvfr/react-dsfr/Header";
5+
import { createEmotionSsrAdvancedApproach } from "tss-react/next";
56

67
const {
78
withDsfr,
@@ -23,7 +24,11 @@ const {
2324
"doPersistDarkModePreferenceWithCookie": true
2425
});
2526

26-
export { dsfrDocumentApi };
27+
const { augmentDocumentWithEmotionCache, withAppEmotionCache} = createEmotionSsrAdvancedApproach({
28+
"key": "css"
29+
});
30+
31+
export { dsfrDocumentApi, augmentDocumentWithEmotionCache };
2732

2833
function App({ Component, pageProps }: AppProps) {
2934
return (
@@ -55,4 +60,4 @@ function App({ Component, pageProps }: AppProps) {
5560
);
5661
}
5762

58-
export default withDsfr(App);
63+
export default withAppEmotionCache(withDsfr(App));

test/integration/next/pages/_document.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Html, Head, Main, NextScript } from "next/document";
22
import type { DocumentProps } from "next/document";
3-
import { dsfrDocumentApi } from "./_app";
3+
import { dsfrDocumentApi, augmentDocumentWithEmotionCache } from "./_app";
44

55
const { augmentDocumentByReadingColorSchemeFromCookie, getColorSchemeHtmlAttributes } = dsfrDocumentApi;
66

@@ -17,3 +17,5 @@ export default function Document(props: DocumentProps) {
1717
}
1818

1919
augmentDocumentByReadingColorSchemeFromCookie(Document);
20+
21+
augmentDocumentWithEmotionCache(Document);

test/integration/next/pages/index.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { useState } from "react";
12
import { Alert } from "@codegouvfr/react-dsfr/Alert";
3+
import { Tabs } from "@codegouvfr/react-dsfr/Tabs";
24
import { useIsDark, fr } from "@codegouvfr/react-dsfr";
35
import { DarkModeSwitch } from "@codegouvfr/react-dsfr/DarkModeSwitch";
6+
import { useStyles } from "tss-react/dsfr";
47

58
export default function App() {
69
const { isDark, setIsDark } = useIsDark();
710

11+
812
return (
913
<>
1014
<Alert
@@ -13,6 +17,7 @@ export default function App() {
1317
title="Success: This is the title"
1418
description="This is the description"
1519
/>
20+
<ControlledTabs />
1621

1722
<button className={fr.cx("fr-btn", "fr-icon-checkbox-circle-line", "fr-btn--icon-left")}>
1823
Label bouton MD
@@ -34,3 +39,28 @@ export default function App() {
3439
</>
3540
);
3641
}
42+
43+
function ControlledTabs() {
44+
45+
const [selectedTabId, setSelectedTabId] = useState("tab1");
46+
47+
const { css } = useStyles();
48+
49+
return (
50+
<Tabs
51+
className={css({
52+
"margin": fr.spacing("3v")
53+
})}
54+
selectedTabId={selectedTabId}
55+
tabs={[
56+
{ "tabId": "tab1", "label": "Tab 1", "iconId": "fr-icon-add-line" },
57+
{ "tabId": "tab2", "label": "Tab 2", "iconId": "fr-icon-ball-pen-fill" },
58+
{ "tabId": "tab3", "label": "Tab 3" },
59+
]}
60+
onTabChange={setSelectedTabId}
61+
>
62+
<p>Content of {selectedTabId}</p>
63+
</Tabs>
64+
);
65+
66+
}

0 commit comments

Comments
 (0)