Skip to content

Commit c33672f

Browse files
feat: add sidemenu component (#79)
* feat: add sidemenu component * add isActive to item props * remove tiles refs * add positioning: left, righy, sticky * multi levels * burger menu button text * bump * add next appdir example * add cra example * small fix * mark side menu as done * bump * add story arguments types * Update src/SideMenu.tsx Co-authored-by: Joseph Garrone <joseph.garrone.gj@gmail.com> Signed-off-by: Gary van Woerkens <gary.van-woerkens@sg.social.gouv.fr> * Update src/SideMenu.tsx Co-authored-by: Joseph Garrone <joseph.garrone.gj@gmail.com> Signed-off-by: Gary van Woerkens <gary.van-woerkens@sg.social.gouv.fr> * Update src/SideMenu.tsx Co-authored-by: Joseph Garrone <joseph.garrone.gj@gmail.com> Signed-off-by: Gary van Woerkens <gary.van-woerkens@sg.social.gouv.fr> * add classes, fix types and sticky full height --------- Signed-off-by: Gary van Woerkens <gary.van-woerkens@sg.social.gouv.fr> Co-authored-by: Joseph Garrone <joseph.garrone.gj@gmail.com>
1 parent 4bca7b8 commit c33672f

File tree

7 files changed

+632
-4
lines changed

7 files changed

+632
-4
lines changed

COMPONENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
- [ ] Link
2424
- [x] SkipLinks
2525
- [x] Select
26-
- [ ] Side menu
26+
- [x] Side menu
2727
- [x] Call out
2828
- [x] Highlight
2929
- [x] Modal (Uncontrolled variant done, still needs to do the controlled one)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"./Breadcrumb": "./dist/Breadcrumb.js",
161161
"./Badge": "./dist/Badge.js",
162162
"./Alert": "./dist/Alert.js",
163-
"./Accordion": "./dist/Accordion.js"
163+
"./Accordion": "./dist/Accordion.js",
164+
"./SideMenu": "./dist/SideMenu.js"
164165
}
165166
}

src/SideMenu.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
10+
//https://main--ds-gouv.netlify.app/example/component/sidemenu/
11+
export type SideMenuProps = {
12+
title?: ReactNode;
13+
className?: string;
14+
style?: CSSProperties;
15+
align?: "left" | "right";
16+
items: SideMenuProps.Item[];
17+
bugerMenuButtonText: ReactNode;
18+
/** Default: false */
19+
sticky?: boolean;
20+
/** Default: false, only relevent when sticky */
21+
fullHeight?: boolean;
22+
classes?: Partial<
23+
Record<"root" | "inner" | "title" | "list" | "item" | "link" | "button", string>
24+
>;
25+
};
26+
27+
export namespace SideMenuProps {
28+
export type Item = Item.Link & Item.SubMenu;
29+
30+
export namespace Item {
31+
type Common = {
32+
text: ReactNode;
33+
/** Default: false */
34+
isActive?: boolean;
35+
};
36+
37+
export type Link = Common & {
38+
linkProps: RegisteredLinkProps;
39+
};
40+
41+
export type SubMenu = Common & {
42+
items: Item[];
43+
};
44+
}
45+
}
46+
47+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-sidemenu> */
48+
export const SideMenu = memo(
49+
forwardRef<HTMLDivElement, SideMenuProps>((props, ref) => {
50+
const {
51+
title,
52+
items,
53+
style,
54+
sticky,
55+
className,
56+
fullHeight,
57+
classes = {},
58+
align = "left",
59+
bugerMenuButtonText,
60+
...rest
61+
} = props;
62+
63+
assert<Equals<keyof typeof rest, never>>();
64+
65+
const { Link } = getLink();
66+
67+
const getItem = (
68+
{ isActive, linkProps, text, items }: SideMenuProps.Item,
69+
key: number,
70+
level = 0
71+
) => {
72+
if (++level > 2) return null;
73+
74+
return (
75+
<li key={key} className={cx(fr.cx("fr-sidemenu__item"), classes.item)}>
76+
{items ? (
77+
<>
78+
<button
79+
aria-expanded="false"
80+
aria-controls={`fr-sidemenu-item-${key}`}
81+
{...(isActive && { ["aria-current"]: true })}
82+
className={cx(fr.cx("fr-sidemenu__btn"), classes.button)}
83+
>
84+
{text}
85+
</button>
86+
<div className={fr.cx("fr-collapse")} id={`fr-sidemenu-item-${key}`}>
87+
<ul className={cx(fr.cx("fr-sidemenu__list"), classes.list)}>
88+
{items.map((item, i) => getItem(item, i, level))}
89+
</ul>
90+
</div>
91+
</>
92+
) : (
93+
<Link
94+
target="_self"
95+
{...linkProps}
96+
{...(isActive && { ["aria-current"]: "page" })}
97+
className={cx(
98+
fr.cx("fr-sidemenu__link"),
99+
classes.link,
100+
linkProps?.className
101+
)}
102+
>
103+
{text}
104+
</Link>
105+
)}
106+
</li>
107+
);
108+
};
109+
110+
return (
111+
<nav
112+
{...rest}
113+
ref={ref}
114+
style={style}
115+
aria-labelledby="fr-sidemenu-title"
116+
className={cx(
117+
fr.cx("fr-sidemenu", {
118+
"fr-sidemenu--right": align === "right",
119+
"fr-sidemenu--sticky": sticky && !fullHeight,
120+
"fr-sidemenu--sticky-full-height": sticky && fullHeight
121+
}),
122+
classes.root,
123+
className
124+
)}
125+
>
126+
<div className={cx(fr.cx("fr-sidemenu__inner"), classes.inner)}>
127+
<button
128+
hidden
129+
aria-expanded="false"
130+
aria-controls="fr-sidemenu-wrapper"
131+
className={cx(fr.cx("fr-sidemenu__btn"), classes.button)}
132+
>
133+
{bugerMenuButtonText}
134+
</button>
135+
<div className={fr.cx("fr-collapse")} id="fr-sidemenu-wrapper">
136+
{title !== undefined && (
137+
<div
138+
className={cx(fr.cx("fr-sidemenu__title"), classes.title)}
139+
id="fr-sidemenu-title"
140+
>
141+
{title}
142+
</div>
143+
)}
144+
<ul className={cx(fr.cx("fr-sidemenu__list"), classes.list)}>
145+
{items.map((item, i) => getItem(item, i))}
146+
</ul>
147+
</div>
148+
</div>
149+
</nav>
150+
);
151+
})
152+
);
153+
154+
SideMenu.displayName = symToStr({ SideMenu });
155+
156+
export default SideMenu;

0 commit comments

Comments
 (0)