UNPKG

9.96 kBTypeScriptView Raw
1import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react";
2import { symToStr } from "tsafe/symToStr";
3import { assert } from "tsafe/assert";
4import type { Equals } from "tsafe";
5import type { RegisteredLinkProps } from "./link";
6import { getLink } from "./link";
7import { fr } from "./fr";
8import { cx } from "./tools/cx";
9import { useAnalyticsId } from "./tools/useAnalyticsId";
10
11//https://main--ds-gouv.netlify.app/example/component/sidemenu/
12export 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
29export 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> */
53export 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
223SideMenu.displayName = symToStr({ SideMenu });
224
225export default SideMenu;
226
\No newline at end of file