1 | import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react";
|
2 | import { symToStr } from "tsafe/symToStr";
|
3 | import { assert } from "tsafe/assert";
|
4 | import type { Equals } from "tsafe";
|
5 | import type { RegisteredLinkProps } from "./link";
|
6 | import { getLink } from "./link";
|
7 | import { fr } from "./fr";
|
8 | import { cx } from "./tools/cx";
|
9 | import { useAnalyticsId } from "./tools/useAnalyticsId";
|
10 |
|
11 | //https://main--ds-gouv.netlify.app/example/component/sidemenu/
|
12 | export type SideMenuProps = {
|
13 | id?: string;
|
14 | title?: ReactNode;
|
15 | className?: string;
|
16 | style?: CSSProperties;
|
17 | align?: "left" | "right";
|
18 | items: SideMenuProps.Item[];
|
19 | burgerMenuButtonText: ReactNode;
|
20 | /** Default: false */
|
21 | sticky?: boolean;
|
22 | /** Default: false, only relevent when sticky */
|
23 | fullHeight?: boolean;
|
24 | classes?: Partial<
|
25 | Record<"root" | "inner" | "title" | "list" | "item" | "link" | "button", string>
|
26 | >;
|
27 | };
|
28 |
|
29 | export namespace SideMenuProps {
|
30 | export type Item = Item.Link | Item.SubMenu;
|
31 |
|
32 | export namespace Item {
|
33 | type Common = {
|
34 | text: ReactNode;
|
35 | /** Default: false */
|
36 | isActive?: boolean;
|
37 | };
|
38 |
|
39 | export type Link = Common & {
|
40 | linkProps: RegisteredLinkProps;
|
41 | };
|
42 |
|
43 | export type SubMenu = Common & {
|
44 | items: Item[];
|
45 | /** Default: false */
|
46 | expandedByDefault?: boolean;
|
47 | linkProps?: RegisteredLinkProps;
|
48 | };
|
49 | }
|
50 | }
|
51 |
|
52 | /** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-sidemenu> */
|
53 | export const SideMenu = memo(
|
54 | forwardRef<HTMLDivElement, SideMenuProps>((props, ref) => {
|
55 | const {
|
56 | id: id_props,
|
57 | title,
|
58 | items,
|
59 | style,
|
60 | sticky,
|
61 | className,
|
62 | fullHeight,
|
63 | classes = {},
|
64 | align = "left",
|
65 | burgerMenuButtonText,
|
66 | ...rest
|
67 | } = props;
|
68 |
|
69 | assert<Equals<keyof typeof rest, never>>();
|
70 |
|
71 | const { Link } = getLink();
|
72 |
|
73 | const id = useAnalyticsId({
|
74 | "defaultIdPrefix": "fr-sidemenu",
|
75 | "explicitlyProvidedId": id_props
|
76 | });
|
77 |
|
78 | const collapseId = `${id}-collapse`;
|
79 |
|
80 | const titleId = `${id}-title`;
|
81 |
|
82 | const getItemId = (params: { level: number; key: string }) => {
|
83 | const { level, key } = params;
|
84 |
|
85 | return `fr-sidemenu-item-${id}-${level}-${key}`;
|
86 | };
|
87 |
|
88 | return (
|
89 | <nav
|
90 | id={id}
|
91 | {...rest}
|
92 | ref={ref}
|
93 | style={style}
|
94 | aria-labelledby={titleId}
|
95 | className={cx(
|
96 | fr.cx("fr-sidemenu", {
|
97 | "fr-sidemenu--right": align === "right",
|
98 | "fr-sidemenu--sticky": sticky && !fullHeight,
|
99 | "fr-sidemenu--sticky-full-height": sticky && fullHeight
|
100 | }),
|
101 | classes.root,
|
102 | className
|
103 | )}
|
104 | >
|
105 | <div className={cx(fr.cx("fr-sidemenu__inner"), classes.inner)}>
|
106 | <button
|
107 | hidden
|
108 | aria-expanded="false"
|
109 | aria-controls={collapseId}
|
110 | className={cx(fr.cx("fr-sidemenu__btn"), classes.button)}
|
111 | >
|
112 | {burgerMenuButtonText}
|
113 | </button>
|
114 | <div className={fr.cx("fr-collapse")} id={collapseId}>
|
115 | {title !== undefined && (
|
116 | <div
|
117 | className={cx(fr.cx("fr-sidemenu__title"), classes.title)}
|
118 | id={titleId}
|
119 | >
|
120 | {title}
|
121 | </div>
|
122 | )}
|
123 | <ul className={cx(fr.cx("fr-sidemenu__list"), classes.list)}>
|
124 | {items.map((item, i) => {
|
125 | const getItemRec = (params: {
|
126 | item: SideMenuProps.Item;
|
127 | key: string;
|
128 | level: number;
|
129 | }) => {
|
130 | const { item, key, level } = params;
|
131 |
|
132 | const itemId = getItemId({ key, level });
|
133 |
|
134 | return (
|
135 | <li
|
136 | key={key}
|
137 | className={cx(fr.cx("fr-sidemenu__item"), classes.item)}
|
138 | >
|
139 | {"items" in item ? (
|
140 | <>
|
141 | {(() => {
|
142 | const ComponentToUse =
|
143 | item.linkProps !== undefined
|
144 | ? Link
|
145 | : "button";
|
146 |
|
147 | return (
|
148 | // @ts-expect-error
|
149 | <ComponentToUse
|
150 | aria-expanded={
|
151 | item.expandedByDefault ?? false
|
152 | ? "true"
|
153 | : "false"
|
154 | }
|
155 | aria-controls={itemId}
|
156 | {...(item.isActive && {
|
157 | ["aria-current"]: true
|
158 | })}
|
159 | className={cx(
|
160 | fr.cx("fr-sidemenu__btn"),
|
161 | classes.button
|
162 | )}
|
163 | {...item.linkProps}
|
164 | >
|
165 | {item.text}
|
166 | </ComponentToUse>
|
167 | );
|
168 | })()}
|
169 | <div
|
170 | className={fr.cx("fr-collapse")}
|
171 | id={itemId}
|
172 | >
|
173 | <ul
|
174 | className={cx(
|
175 | fr.cx("fr-sidemenu__list"),
|
176 | classes.list
|
177 | )}
|
178 | >
|
179 | {item.items.map((item, i) =>
|
180 | getItemRec({
|
181 | item,
|
182 | "key": `${i}`,
|
183 | "level": level + 1
|
184 | })
|
185 | )}
|
186 | </ul>
|
187 | </div>
|
188 | </>
|
189 | ) : (
|
190 | <Link
|
191 | target="_self"
|
192 | {...item.linkProps}
|
193 | {...(item.isActive && {
|
194 | ["aria-current"]: "page"
|
195 | })}
|
196 | className={cx(
|
197 | fr.cx("fr-sidemenu__link"),
|
198 | classes.link,
|
199 | item.linkProps?.className
|
200 | )}
|
201 | >
|
202 | {item.text}
|
203 | </Link>
|
204 | )}
|
205 | </li>
|
206 | );
|
207 | };
|
208 |
|
209 | return getItemRec({
|
210 | "key": `${i}`,
|
211 | item,
|
212 | "level": 0
|
213 | });
|
214 | })}
|
215 | </ul>
|
216 | </div>
|
217 | </div>
|
218 | </nav>
|
219 | );
|
220 | })
|
221 | );
|
222 |
|
223 | SideMenu.displayName = symToStr({ SideMenu });
|
224 |
|
225 | export default SideMenu;
|
226 |
|
\ | No newline at end of file |