@justin_evo/evo-ui 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +70 -70
- package/dist/declarations.d.ts +6 -6
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -886
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -1163
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -123
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -568
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- package/src/index.ts +60 -60
package/src/Nav/Nav.tsx
CHANGED
|
@@ -1,708 +1,708 @@
|
|
|
1
|
-
// EvoNav — compose-based sidebar navigation.
|
|
2
|
-
//
|
|
3
|
-
// API decisions (see Radix Navigation Menu, Mantine NavLink, shadcn Sidebar):
|
|
4
|
-
// - Compose over config: nesting is always children, never an `items` array.
|
|
5
|
-
// - Disclosure state is controlled (`open` + `onOpenChange`) or uncontrolled
|
|
6
|
-
// (`defaultOpen`), matching the Evo naming rule from CLAUDE.md §0.1.
|
|
7
|
-
// - `active` keeps its name (React community convention) but now always
|
|
8
|
-
// forwards `aria-current="page"` — fixes the a11y gap called out in §9.
|
|
9
|
-
// - Rows render as `<a href>` when `href` is set, `<button type="button">`
|
|
10
|
-
// otherwise. This preserves right-click / middle-click / drag semantics
|
|
11
|
-
// without forcing every consumer onto a router.
|
|
12
|
-
// - At viewport widths below `breakpoint` (default 768px), the nav collapses
|
|
13
|
-
// to an off-canvas drawer with a built-in hamburger trigger. Trigger can
|
|
14
|
-
// be lifted with `hideTrigger` + controlled `drawerOpen`.
|
|
15
|
-
// - Keyboard model is a "disclosure tree" (Mantine-style), not Radix's
|
|
16
|
-
// roving-tabindex menu: every row is in the natural tab order, arrow keys
|
|
17
|
-
// move focus within the nav, ←/→ collapse/expand.
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
createContext,
|
|
21
|
-
forwardRef,
|
|
22
|
-
isValidElement,
|
|
23
|
-
useCallback,
|
|
24
|
-
useContext,
|
|
25
|
-
useEffect,
|
|
26
|
-
useId,
|
|
27
|
-
useMemo,
|
|
28
|
-
useRef,
|
|
29
|
-
useState,
|
|
30
|
-
type AnchorHTMLAttributes,
|
|
31
|
-
type ButtonHTMLAttributes,
|
|
32
|
-
type CSSProperties,
|
|
33
|
-
type HTMLAttributes,
|
|
34
|
-
type KeyboardEvent as ReactKeyboardEvent,
|
|
35
|
-
type MouseEvent as ReactMouseEvent,
|
|
36
|
-
type ReactNode,
|
|
37
|
-
type RefObject,
|
|
38
|
-
} from 'react';
|
|
39
|
-
import styles from '../css/nav.module.scss';
|
|
40
|
-
|
|
41
|
-
// ─── Public types ──────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
|
|
44
|
-
children: ReactNode;
|
|
45
|
-
/** Below this viewport width (px), nav collapses to a drawer. @default 768 */
|
|
46
|
-
breakpoint?: number;
|
|
47
|
-
/** Controlled drawer open state (mobile only). */
|
|
48
|
-
drawerOpen?: boolean;
|
|
49
|
-
/** Uncontrolled initial drawer state. @default false */
|
|
50
|
-
defaultDrawerOpen?: boolean;
|
|
51
|
-
/** Called when the drawer opens or closes. */
|
|
52
|
-
onDrawerOpenChange?: (open: boolean) => void;
|
|
53
|
-
/** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
|
|
54
|
-
hideTrigger?: boolean;
|
|
55
|
-
/** Collapse to an icon-only rail: labels hide, icons center, and each row
|
|
56
|
-
* shows a native tooltip from its `tooltip` prop. @default false */
|
|
57
|
-
collapsed?: boolean;
|
|
58
|
-
/** Accessible label for the <nav> landmark. @default 'Main navigation' */
|
|
59
|
-
'aria-label'?: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface EvoNavGroupProps {
|
|
63
|
-
label: string;
|
|
64
|
-
children: ReactNode;
|
|
65
|
-
className?: string;
|
|
66
|
-
/** Render the heading as a disclosure that expands/collapses the group. */
|
|
67
|
-
collapsible?: boolean;
|
|
68
|
-
/** Uncontrolled initial open state (collapsible only). @default true */
|
|
69
|
-
defaultOpen?: boolean;
|
|
70
|
-
/** Controlled open state (collapsible only). */
|
|
71
|
-
open?: boolean;
|
|
72
|
-
/** Called when the group expands or collapses. */
|
|
73
|
-
onOpenChange?: (open: boolean) => void;
|
|
74
|
-
/** Small count chip shown after the label. */
|
|
75
|
-
count?: number;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface EvoNavRowProps {
|
|
79
|
-
children: ReactNode;
|
|
80
|
-
icon?: ReactNode;
|
|
81
|
-
/** Marks this row as the current page (sets aria-current="page"). */
|
|
82
|
-
active?: boolean;
|
|
83
|
-
/** Render as <a href> instead of <button>. */
|
|
84
|
-
href?: string;
|
|
85
|
-
onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
|
|
86
|
-
/** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
|
|
87
|
-
tooltip?: string;
|
|
88
|
-
/** Controlled expand state (only when row has SubItem children). */
|
|
89
|
-
open?: boolean;
|
|
90
|
-
/** Uncontrolled initial expand state. */
|
|
91
|
-
defaultOpen?: boolean;
|
|
92
|
-
onOpenChange?: (open: boolean) => void;
|
|
93
|
-
disabled?: boolean;
|
|
94
|
-
className?: string;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface EvoNavItemProps extends EvoNavRowProps {}
|
|
98
|
-
export interface EvoNavSubItemProps extends EvoNavRowProps {}
|
|
99
|
-
|
|
100
|
-
export interface EvoNavSkeletonProps {
|
|
101
|
-
/** @default 4 */
|
|
102
|
-
count?: number;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface EvoNavQuickActionProps
|
|
106
|
-
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
107
|
-
/** @default 'Create New' */
|
|
108
|
-
label?: string;
|
|
109
|
-
icon?: ReactNode;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ─── Internal context ──────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
interface NavRootContextValue {
|
|
115
|
-
/** Whether the viewport is below `breakpoint`. */
|
|
116
|
-
isMobile: boolean;
|
|
117
|
-
/** Drawer open state (only meaningful when isMobile). */
|
|
118
|
-
drawerOpen: boolean;
|
|
119
|
-
setDrawerOpen: (open: boolean) => void;
|
|
120
|
-
/** Closes the drawer; safe to call regardless of mobile state. */
|
|
121
|
-
closeDrawer: () => void;
|
|
122
|
-
/** Root id used by the hamburger button's aria-controls. */
|
|
123
|
-
rootId: string;
|
|
124
|
-
/** Icon-only rail mode — labels hide, rows surface a native tooltip. */
|
|
125
|
-
collapsed: boolean;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const NavRootContext = createContext<NavRootContextValue | null>(null);
|
|
129
|
-
|
|
130
|
-
interface NavDepthContextValue {
|
|
131
|
-
depth: number;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const NavDepthContext = createContext<NavDepthContextValue>({ depth: 0 });
|
|
135
|
-
|
|
136
|
-
// ─── Hooks ────────────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
function useIsBelowWidth(maxWidth: number): boolean {
|
|
139
|
-
const [isBelow, setIsBelow] = useState(false);
|
|
140
|
-
|
|
141
|
-
useEffect(() => {
|
|
142
|
-
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
143
|
-
const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
|
|
144
|
-
const update = () => setIsBelow(mql.matches);
|
|
145
|
-
update();
|
|
146
|
-
mql.addEventListener('change', update);
|
|
147
|
-
return () => mql.removeEventListener('change', update);
|
|
148
|
-
}, [maxWidth]);
|
|
149
|
-
|
|
150
|
-
return isBelow;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Resolve controlled vs. uncontrolled state. */
|
|
154
|
-
function useControllableState<T>(
|
|
155
|
-
controlled: T | undefined,
|
|
156
|
-
defaultValue: T,
|
|
157
|
-
onChange?: (value: T) => void,
|
|
158
|
-
): [T, (value: T) => void] {
|
|
159
|
-
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
160
|
-
const isControlled = controlled !== undefined;
|
|
161
|
-
const value = isControlled ? (controlled as T) : uncontrolled;
|
|
162
|
-
const setValue = useCallback(
|
|
163
|
-
(next: T) => {
|
|
164
|
-
if (!isControlled) setUncontrolled(next);
|
|
165
|
-
onChange?.(next);
|
|
166
|
-
},
|
|
167
|
-
[isControlled, onChange],
|
|
168
|
-
);
|
|
169
|
-
return [value, setValue];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ─── Keyboard navigation ──────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
const ROW_ATTR = 'data-evo-nav-row';
|
|
175
|
-
|
|
176
|
-
function focusableRows(root: HTMLElement | null): HTMLElement[] {
|
|
177
|
-
if (!root) return [];
|
|
178
|
-
return Array.from(
|
|
179
|
-
root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
|
|
180
|
-
).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
|
|
184
|
-
const rows = focusableRows(root);
|
|
185
|
-
if (rows.length === 0) return;
|
|
186
|
-
const idx = rows.indexOf(from);
|
|
187
|
-
let nextIdx: number;
|
|
188
|
-
if (delta === 'first') nextIdx = 0;
|
|
189
|
-
else if (delta === 'last') nextIdx = rows.length - 1;
|
|
190
|
-
else nextIdx = Math.max(0, Math.min(rows.length - 1, idx + delta));
|
|
191
|
-
rows[nextIdx]?.focus();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ─── Chevron / plus icons ─────────────────────────────────────────────────
|
|
195
|
-
|
|
196
|
-
const ChevronIcon = ({ open }: { open: boolean }) => (
|
|
197
|
-
<svg
|
|
198
|
-
viewBox="0 0 16 16"
|
|
199
|
-
fill="none"
|
|
200
|
-
width="12"
|
|
201
|
-
height="12"
|
|
202
|
-
aria-hidden="true"
|
|
203
|
-
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
|
|
204
|
-
>
|
|
205
|
-
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
206
|
-
</svg>
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
const PlusIcon = () => (
|
|
210
|
-
<svg viewBox="0 0 16 16" fill="none" width="14" height="14" aria-hidden="true">
|
|
211
|
-
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
212
|
-
</svg>
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
const HamburgerIcon = () => (
|
|
216
|
-
<svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
|
|
217
|
-
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
218
|
-
</svg>
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
const CloseIcon = () => (
|
|
222
|
-
<svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
|
|
223
|
-
<path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
224
|
-
</svg>
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
// ─── Row (Item / SubItem shared implementation) ───────────────────────────
|
|
228
|
-
|
|
229
|
-
interface RowInternalProps extends EvoNavRowProps {
|
|
230
|
-
/** Visual tier — affects font weight and density. */
|
|
231
|
-
tier: 'item' | 'subitem';
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function splitSubItemChildren(children: ReactNode): { label: ReactNode[]; subs: ReactNode[] } {
|
|
235
|
-
const label: ReactNode[] = [];
|
|
236
|
-
const subs: ReactNode[] = [];
|
|
237
|
-
const walk = (node: ReactNode) => {
|
|
238
|
-
if (Array.isArray(node)) {
|
|
239
|
-
node.forEach(walk);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (isValidElement(node)) {
|
|
243
|
-
const type = node.type as { displayName?: string } | string;
|
|
244
|
-
if (typeof type !== 'string' && type?.displayName === 'EvoNavSubItem') {
|
|
245
|
-
subs.push(node);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
label.push(node);
|
|
250
|
-
};
|
|
251
|
-
walk(children);
|
|
252
|
-
return { label, subs };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
|
|
256
|
-
{
|
|
257
|
-
children,
|
|
258
|
-
icon,
|
|
259
|
-
active = false,
|
|
260
|
-
href,
|
|
261
|
-
onClick,
|
|
262
|
-
tooltip,
|
|
263
|
-
open: openProp,
|
|
264
|
-
defaultOpen = false,
|
|
265
|
-
onOpenChange,
|
|
266
|
-
disabled = false,
|
|
267
|
-
className,
|
|
268
|
-
tier,
|
|
269
|
-
},
|
|
270
|
-
liRef,
|
|
271
|
-
) {
|
|
272
|
-
const rootCtx = useContext(NavRootContext);
|
|
273
|
-
const collapsed = rootCtx?.collapsed ?? false;
|
|
274
|
-
const { depth } = useContext(NavDepthContext);
|
|
275
|
-
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
|
276
|
-
const rowId = useId();
|
|
277
|
-
const subListId = `${rowId}-sub`;
|
|
278
|
-
|
|
279
|
-
const { label, subs } = useMemo(() => splitSubItemChildren(children), [children]);
|
|
280
|
-
const expandable = subs.length > 0;
|
|
281
|
-
const [open, setOpen] = useControllableState(
|
|
282
|
-
expandable ? openProp : false,
|
|
283
|
-
expandable ? defaultOpen : false,
|
|
284
|
-
onOpenChange,
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const toggle = useCallback(() => {
|
|
288
|
-
if (!expandable) return;
|
|
289
|
-
setOpen(!open);
|
|
290
|
-
}, [expandable, open, setOpen]);
|
|
291
|
-
|
|
292
|
-
const handleActivate = useCallback(
|
|
293
|
-
(event: ReactMouseEvent | ReactKeyboardEvent) => {
|
|
294
|
-
if (disabled) return;
|
|
295
|
-
if (expandable && !href) {
|
|
296
|
-
// No real navigation target — primary click toggles disclosure.
|
|
297
|
-
toggle();
|
|
298
|
-
}
|
|
299
|
-
onClick?.(event);
|
|
300
|
-
// On mobile, navigating closes the drawer so the user lands on the page.
|
|
301
|
-
if (rootCtx?.isMobile && href && !expandable) {
|
|
302
|
-
rootCtx.closeDrawer();
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
[disabled, expandable, href, onClick, rootCtx, toggle],
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
const handleKeyDown = (event: ReactKeyboardEvent) => {
|
|
309
|
-
if (disabled) return;
|
|
310
|
-
const self = event.currentTarget as HTMLElement;
|
|
311
|
-
const navRoot = self.closest<HTMLElement>(`.${styles.navContainer}`);
|
|
312
|
-
switch (event.key) {
|
|
313
|
-
case 'ArrowDown':
|
|
314
|
-
event.preventDefault();
|
|
315
|
-
moveFocus(navRoot, self, 1);
|
|
316
|
-
break;
|
|
317
|
-
case 'ArrowUp':
|
|
318
|
-
event.preventDefault();
|
|
319
|
-
moveFocus(navRoot, self, -1);
|
|
320
|
-
break;
|
|
321
|
-
case 'ArrowRight':
|
|
322
|
-
if (expandable) {
|
|
323
|
-
event.preventDefault();
|
|
324
|
-
if (!open) {
|
|
325
|
-
setOpen(true);
|
|
326
|
-
} else {
|
|
327
|
-
// Focus first child row.
|
|
328
|
-
const li = self.closest('li');
|
|
329
|
-
const firstChild = li?.querySelector<HTMLElement>(`ul [${ROW_ATTR}]`);
|
|
330
|
-
firstChild?.focus();
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
break;
|
|
334
|
-
case 'ArrowLeft':
|
|
335
|
-
event.preventDefault();
|
|
336
|
-
if (expandable && open) {
|
|
337
|
-
setOpen(false);
|
|
338
|
-
} else if (depth > 0) {
|
|
339
|
-
// Walk up to the nearest ancestor row.
|
|
340
|
-
const parentLi = self.closest('li')?.parentElement?.closest('li');
|
|
341
|
-
parentLi?.querySelector<HTMLElement>(`[${ROW_ATTR}]`)?.focus();
|
|
342
|
-
}
|
|
343
|
-
break;
|
|
344
|
-
case 'Home':
|
|
345
|
-
event.preventDefault();
|
|
346
|
-
moveFocus(navRoot, self, 'first');
|
|
347
|
-
break;
|
|
348
|
-
case 'End':
|
|
349
|
-
event.preventDefault();
|
|
350
|
-
moveFocus(navRoot, self, 'last');
|
|
351
|
-
break;
|
|
352
|
-
case 'Enter':
|
|
353
|
-
case ' ': {
|
|
354
|
-
// <a> already activates on Enter; let it through but capture Space.
|
|
355
|
-
if (event.key === ' ' || href === undefined) {
|
|
356
|
-
event.preventDefault();
|
|
357
|
-
handleActivate(event);
|
|
358
|
-
}
|
|
359
|
-
break;
|
|
360
|
-
}
|
|
361
|
-
default:
|
|
362
|
-
break;
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const rowClasses = [
|
|
367
|
-
styles.navRow,
|
|
368
|
-
tier === 'subitem' ? styles.navRowSub : styles.navRowTop,
|
|
369
|
-
active ? styles.active : '',
|
|
370
|
-
disabled ? styles.disabled : '',
|
|
371
|
-
className,
|
|
372
|
-
]
|
|
373
|
-
.filter(Boolean)
|
|
374
|
-
.join(' ');
|
|
375
|
-
|
|
376
|
-
const rowStyle =
|
|
377
|
-
tier === 'subitem' && depth > 0
|
|
378
|
-
? ({ ['--evo-nav-indent' as string]: `${depth * 0.875}rem` } as CSSProperties)
|
|
379
|
-
: undefined;
|
|
380
|
-
|
|
381
|
-
const commonRowProps = {
|
|
382
|
-
'data-evo-nav-row': '',
|
|
383
|
-
id: rowId,
|
|
384
|
-
className: rowClasses,
|
|
385
|
-
style: rowStyle,
|
|
386
|
-
title: collapsed && tooltip ? tooltip : undefined,
|
|
387
|
-
'aria-current': active ? ('page' as const) : undefined,
|
|
388
|
-
'aria-expanded': expandable ? open : undefined,
|
|
389
|
-
'aria-controls': expandable ? subListId : undefined,
|
|
390
|
-
'aria-disabled': disabled || undefined,
|
|
391
|
-
'data-disabled': disabled ? 'true' : undefined,
|
|
392
|
-
'data-active': active ? 'true' : undefined,
|
|
393
|
-
tabIndex: disabled ? -1 : 0,
|
|
394
|
-
onKeyDown: handleKeyDown,
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
const rowInner = (
|
|
398
|
-
<>
|
|
399
|
-
{icon && <span className={styles.navIcon}>{icon}</span>}
|
|
400
|
-
<span className={styles.navLabel}>{label}</span>
|
|
401
|
-
{expandable && <ChevronIcon open={open} />}
|
|
402
|
-
</>
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
let rowEl: ReactNode;
|
|
406
|
-
if (href && !disabled) {
|
|
407
|
-
const anchorRest: AnchorHTMLAttributes<HTMLAnchorElement> = {
|
|
408
|
-
href,
|
|
409
|
-
onClick: handleActivate as unknown as AnchorHTMLAttributes<HTMLAnchorElement>['onClick'],
|
|
410
|
-
};
|
|
411
|
-
rowEl = (
|
|
412
|
-
<a
|
|
413
|
-
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
|
414
|
-
{...commonRowProps}
|
|
415
|
-
{...anchorRest}
|
|
416
|
-
>
|
|
417
|
-
{rowInner}
|
|
418
|
-
</a>
|
|
419
|
-
);
|
|
420
|
-
} else {
|
|
421
|
-
rowEl = (
|
|
422
|
-
<button
|
|
423
|
-
ref={buttonRef as RefObject<HTMLButtonElement>}
|
|
424
|
-
type="button"
|
|
425
|
-
disabled={disabled}
|
|
426
|
-
onClick={handleActivate}
|
|
427
|
-
{...commonRowProps}
|
|
428
|
-
>
|
|
429
|
-
{rowInner}
|
|
430
|
-
</button>
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return (
|
|
435
|
-
<li ref={liRef} className={styles.navLi}>
|
|
436
|
-
{rowEl}
|
|
437
|
-
{expandable && (
|
|
438
|
-
<NavDepthContext.Provider value={{ depth: depth + 1 }}>
|
|
439
|
-
<ul
|
|
440
|
-
id={subListId}
|
|
441
|
-
role="group"
|
|
442
|
-
aria-labelledby={rowId}
|
|
443
|
-
hidden={!open}
|
|
444
|
-
className={styles.navSubList}
|
|
445
|
-
>
|
|
446
|
-
{subs}
|
|
447
|
-
</ul>
|
|
448
|
-
</NavDepthContext.Provider>
|
|
449
|
-
)}
|
|
450
|
-
</li>
|
|
451
|
-
);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// ─── Public sub-components ────────────────────────────────────────────────
|
|
455
|
-
|
|
456
|
-
export const EvoNavItem = forwardRef<HTMLLIElement, EvoNavItemProps>(function EvoNavItem(
|
|
457
|
-
props,
|
|
458
|
-
ref,
|
|
459
|
-
) {
|
|
460
|
-
return <NavRow ref={ref} tier="item" {...props} />;
|
|
461
|
-
});
|
|
462
|
-
EvoNavItem.displayName = 'EvoNavItem';
|
|
463
|
-
|
|
464
|
-
export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(function EvoNavSubItem(
|
|
465
|
-
props,
|
|
466
|
-
ref,
|
|
467
|
-
) {
|
|
468
|
-
return <NavRow ref={ref} tier="subitem" {...props} />;
|
|
469
|
-
});
|
|
470
|
-
EvoNavSubItem.displayName = 'EvoNavSubItem';
|
|
471
|
-
|
|
472
|
-
export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
|
|
473
|
-
{ label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
|
|
474
|
-
ref,
|
|
475
|
-
) {
|
|
476
|
-
const headingId = useId();
|
|
477
|
-
const panelId = `${headingId}-panel`;
|
|
478
|
-
const collapsed = useContext(NavRootContext)?.collapsed ?? false;
|
|
479
|
-
// The disclosure is interactive only when collapsible AND not in icon-rail
|
|
480
|
-
// mode. In the rail the accordion is meaningless, so we render a static
|
|
481
|
-
// heading and show the items — avoiding a focusable, label-less toggle that
|
|
482
|
-
// would silently mutate the (forced-open) state.
|
|
483
|
-
const interactive = collapsible && !collapsed;
|
|
484
|
-
const [open, setOpen] = useControllableState(
|
|
485
|
-
interactive ? openProp : true,
|
|
486
|
-
interactive ? defaultOpen : true,
|
|
487
|
-
onOpenChange,
|
|
488
|
-
);
|
|
489
|
-
const effectiveOpen = collapsed ? true : open;
|
|
490
|
-
|
|
491
|
-
// Toggle `inert` imperatively rather than through a prop: `inert` is only a
|
|
492
|
-
// managed React attribute as of React 19, but the peer range allows >=17.
|
|
493
|
-
// The DOM API works on every version and keeps the console warning-free.
|
|
494
|
-
const panelRef = useRef<HTMLDivElement>(null);
|
|
495
|
-
useEffect(() => {
|
|
496
|
-
const el = panelRef.current;
|
|
497
|
-
if (!el) return;
|
|
498
|
-
if (effectiveOpen) el.removeAttribute('inert');
|
|
499
|
-
else el.setAttribute('inert', '');
|
|
500
|
-
}, [effectiveOpen]);
|
|
501
|
-
|
|
502
|
-
const countChip =
|
|
503
|
-
count != null ? (
|
|
504
|
-
<span className={styles.navGroupCount} aria-hidden="true">
|
|
505
|
-
{count}
|
|
506
|
-
</span>
|
|
507
|
-
) : null;
|
|
508
|
-
|
|
509
|
-
const list = (
|
|
510
|
-
<ul role="group" aria-labelledby={headingId} className={styles.navList}>
|
|
511
|
-
{children}
|
|
512
|
-
</ul>
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
return (
|
|
516
|
-
<li ref={ref} className={[styles.navGroup, className].filter(Boolean).join(' ')}>
|
|
517
|
-
{interactive ? (
|
|
518
|
-
<button
|
|
519
|
-
type="button"
|
|
520
|
-
id={headingId}
|
|
521
|
-
className={[styles.navGroupLabel, styles.navGroupToggle].join(' ')}
|
|
522
|
-
aria-expanded={effectiveOpen}
|
|
523
|
-
aria-controls={panelId}
|
|
524
|
-
onClick={() => setOpen(!open)}
|
|
525
|
-
>
|
|
526
|
-
<span className={styles.navGroupLabelText}>{label}</span>
|
|
527
|
-
{countChip}
|
|
528
|
-
<ChevronIcon open={effectiveOpen} />
|
|
529
|
-
</button>
|
|
530
|
-
) : (
|
|
531
|
-
<div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
|
|
532
|
-
<span className={styles.navGroupLabelText}>{label}</span>
|
|
533
|
-
{countChip}
|
|
534
|
-
</div>
|
|
535
|
-
)}
|
|
536
|
-
{interactive ? (
|
|
537
|
-
<div
|
|
538
|
-
ref={panelRef}
|
|
539
|
-
id={panelId}
|
|
540
|
-
className={styles.navGroupPanel}
|
|
541
|
-
data-open={effectiveOpen ? 'true' : 'false'}
|
|
542
|
-
>
|
|
543
|
-
{list}
|
|
544
|
-
</div>
|
|
545
|
-
) : (
|
|
546
|
-
list
|
|
547
|
-
)}
|
|
548
|
-
</li>
|
|
549
|
-
);
|
|
550
|
-
});
|
|
551
|
-
EvoNavGroup.displayName = 'EvoNavGroup';
|
|
552
|
-
|
|
553
|
-
export const EvoNavSkeleton = ({ count = 4 }: EvoNavSkeletonProps) => (
|
|
554
|
-
<>
|
|
555
|
-
{Array.from({ length: count }).map((_, i) => (
|
|
556
|
-
<li
|
|
557
|
-
key={i}
|
|
558
|
-
aria-hidden="true"
|
|
559
|
-
className={styles.navSkeletonItem}
|
|
560
|
-
>
|
|
561
|
-
<span className={styles.navSkeletonIcon} />
|
|
562
|
-
<span
|
|
563
|
-
className={styles.navSkeletonText}
|
|
564
|
-
style={{ width: `${45 + (i % 4) * 12}%` }}
|
|
565
|
-
/>
|
|
566
|
-
</li>
|
|
567
|
-
))}
|
|
568
|
-
</>
|
|
569
|
-
);
|
|
570
|
-
|
|
571
|
-
export const EvoNavQuickAction = forwardRef<HTMLButtonElement, EvoNavQuickActionProps>(
|
|
572
|
-
function EvoNavQuickAction({ label = 'Create New', icon, className, type = 'button', ...rest }, ref) {
|
|
573
|
-
return (
|
|
574
|
-
<li className={styles.navLi}>
|
|
575
|
-
<button
|
|
576
|
-
ref={ref}
|
|
577
|
-
type={type}
|
|
578
|
-
className={[styles.navQuickAction, className].filter(Boolean).join(' ')}
|
|
579
|
-
{...rest}
|
|
580
|
-
>
|
|
581
|
-
<span className={styles.navIcon}>{icon ?? <PlusIcon />}</span>
|
|
582
|
-
<span className={styles.navLabel}>{label}</span>
|
|
583
|
-
</button>
|
|
584
|
-
</li>
|
|
585
|
-
);
|
|
586
|
-
},
|
|
587
|
-
);
|
|
588
|
-
EvoNavQuickAction.displayName = 'EvoNavQuickAction';
|
|
589
|
-
|
|
590
|
-
// ─── Root ─────────────────────────────────────────────────────────────────
|
|
591
|
-
|
|
592
|
-
const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
|
|
593
|
-
{
|
|
594
|
-
children,
|
|
595
|
-
breakpoint = 768,
|
|
596
|
-
drawerOpen: drawerOpenProp,
|
|
597
|
-
defaultDrawerOpen = false,
|
|
598
|
-
onDrawerOpenChange,
|
|
599
|
-
hideTrigger = false,
|
|
600
|
-
collapsed = false,
|
|
601
|
-
className,
|
|
602
|
-
'aria-label': ariaLabel = 'Main navigation',
|
|
603
|
-
...rest
|
|
604
|
-
},
|
|
605
|
-
ref,
|
|
606
|
-
) {
|
|
607
|
-
const isMobile = useIsBelowWidth(breakpoint);
|
|
608
|
-
const [drawerOpen, setDrawerOpen] = useControllableState(
|
|
609
|
-
drawerOpenProp,
|
|
610
|
-
defaultDrawerOpen,
|
|
611
|
-
onDrawerOpenChange,
|
|
612
|
-
);
|
|
613
|
-
const closeDrawer = useCallback(() => setDrawerOpen(false), [setDrawerOpen]);
|
|
614
|
-
const rootId = useId();
|
|
615
|
-
const navRef = useRef<HTMLElement>(null);
|
|
616
|
-
|
|
617
|
-
// Close drawer on Escape when on mobile.
|
|
618
|
-
useEffect(() => {
|
|
619
|
-
if (!isMobile || !drawerOpen) return;
|
|
620
|
-
const handler = (e: globalThis.KeyboardEvent) => {
|
|
621
|
-
if (e.key === 'Escape') closeDrawer();
|
|
622
|
-
};
|
|
623
|
-
document.addEventListener('keydown', handler);
|
|
624
|
-
return () => document.removeEventListener('keydown', handler);
|
|
625
|
-
}, [isMobile, drawerOpen, closeDrawer]);
|
|
626
|
-
|
|
627
|
-
// Lock body scroll when drawer is open.
|
|
628
|
-
useEffect(() => {
|
|
629
|
-
if (!isMobile || !drawerOpen) return;
|
|
630
|
-
const prev = document.body.style.overflow;
|
|
631
|
-
document.body.style.overflow = 'hidden';
|
|
632
|
-
return () => {
|
|
633
|
-
document.body.style.overflow = prev;
|
|
634
|
-
};
|
|
635
|
-
}, [isMobile, drawerOpen]);
|
|
636
|
-
|
|
637
|
-
// When transitioning back to desktop width, ensure drawer is closed so
|
|
638
|
-
// subsequent shrinks reopen the sidebar cleanly.
|
|
639
|
-
useEffect(() => {
|
|
640
|
-
if (!isMobile && drawerOpen) closeDrawer();
|
|
641
|
-
}, [isMobile, drawerOpen, closeDrawer]);
|
|
642
|
-
|
|
643
|
-
const ctxValue = useMemo<NavRootContextValue>(
|
|
644
|
-
() => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
|
|
645
|
-
[isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
const navClasses = [
|
|
649
|
-
styles.navContainer,
|
|
650
|
-
isMobile ? styles.navMobile : styles.navDesktop,
|
|
651
|
-
isMobile && drawerOpen ? styles.navDrawerOpen : '',
|
|
652
|
-
collapsed ? styles.navCollapsed : '',
|
|
653
|
-
className,
|
|
654
|
-
]
|
|
655
|
-
.filter(Boolean)
|
|
656
|
-
.join(' ');
|
|
657
|
-
|
|
658
|
-
const setRefs = (el: HTMLElement | null) => {
|
|
659
|
-
(navRef as RefObject<HTMLElement | null>).current = el;
|
|
660
|
-
if (typeof ref === 'function') ref(el);
|
|
661
|
-
else if (ref) (ref as RefObject<HTMLElement | null>).current = el;
|
|
662
|
-
};
|
|
663
|
-
|
|
664
|
-
return (
|
|
665
|
-
<NavRootContext.Provider value={ctxValue}>
|
|
666
|
-
{isMobile && !hideTrigger && (
|
|
667
|
-
<button
|
|
668
|
-
type="button"
|
|
669
|
-
className={styles.navTrigger}
|
|
670
|
-
aria-expanded={drawerOpen}
|
|
671
|
-
aria-controls={rootId}
|
|
672
|
-
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
|
|
673
|
-
onClick={() => setDrawerOpen(!drawerOpen)}
|
|
674
|
-
>
|
|
675
|
-
{drawerOpen ? <CloseIcon /> : <HamburgerIcon />}
|
|
676
|
-
</button>
|
|
677
|
-
)}
|
|
678
|
-
{isMobile && drawerOpen && (
|
|
679
|
-
<div
|
|
680
|
-
className={styles.navBackdrop}
|
|
681
|
-
aria-hidden="true"
|
|
682
|
-
onClick={closeDrawer}
|
|
683
|
-
/>
|
|
684
|
-
)}
|
|
685
|
-
<nav
|
|
686
|
-
ref={setRefs}
|
|
687
|
-
id={rootId}
|
|
688
|
-
role="navigation"
|
|
689
|
-
aria-label={ariaLabel}
|
|
690
|
-
aria-hidden={isMobile && !drawerOpen ? true : undefined}
|
|
691
|
-
className={navClasses}
|
|
692
|
-
{...rest}
|
|
693
|
-
>
|
|
694
|
-
<ul className={styles.navList}>{children}</ul>
|
|
695
|
-
</nav>
|
|
696
|
-
</NavRootContext.Provider>
|
|
697
|
-
);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
EvoNavRoot.displayName = 'EvoNav';
|
|
701
|
-
|
|
702
|
-
export const EvoNav = Object.assign(EvoNavRoot, {
|
|
703
|
-
Group: EvoNavGroup,
|
|
704
|
-
Item: EvoNavItem,
|
|
705
|
-
SubItem: EvoNavSubItem,
|
|
706
|
-
Skeleton: EvoNavSkeleton,
|
|
707
|
-
QuickAction: EvoNavQuickAction,
|
|
708
|
-
});
|
|
1
|
+
// EvoNav — compose-based sidebar navigation.
|
|
2
|
+
//
|
|
3
|
+
// API decisions (see Radix Navigation Menu, Mantine NavLink, shadcn Sidebar):
|
|
4
|
+
// - Compose over config: nesting is always children, never an `items` array.
|
|
5
|
+
// - Disclosure state is controlled (`open` + `onOpenChange`) or uncontrolled
|
|
6
|
+
// (`defaultOpen`), matching the Evo naming rule from CLAUDE.md §0.1.
|
|
7
|
+
// - `active` keeps its name (React community convention) but now always
|
|
8
|
+
// forwards `aria-current="page"` — fixes the a11y gap called out in §9.
|
|
9
|
+
// - Rows render as `<a href>` when `href` is set, `<button type="button">`
|
|
10
|
+
// otherwise. This preserves right-click / middle-click / drag semantics
|
|
11
|
+
// without forcing every consumer onto a router.
|
|
12
|
+
// - At viewport widths below `breakpoint` (default 768px), the nav collapses
|
|
13
|
+
// to an off-canvas drawer with a built-in hamburger trigger. Trigger can
|
|
14
|
+
// be lifted with `hideTrigger` + controlled `drawerOpen`.
|
|
15
|
+
// - Keyboard model is a "disclosure tree" (Mantine-style), not Radix's
|
|
16
|
+
// roving-tabindex menu: every row is in the natural tab order, arrow keys
|
|
17
|
+
// move focus within the nav, ←/→ collapse/expand.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createContext,
|
|
21
|
+
forwardRef,
|
|
22
|
+
isValidElement,
|
|
23
|
+
useCallback,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useId,
|
|
27
|
+
useMemo,
|
|
28
|
+
useRef,
|
|
29
|
+
useState,
|
|
30
|
+
type AnchorHTMLAttributes,
|
|
31
|
+
type ButtonHTMLAttributes,
|
|
32
|
+
type CSSProperties,
|
|
33
|
+
type HTMLAttributes,
|
|
34
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
35
|
+
type MouseEvent as ReactMouseEvent,
|
|
36
|
+
type ReactNode,
|
|
37
|
+
type RefObject,
|
|
38
|
+
} from 'react';
|
|
39
|
+
import styles from '../css/nav.module.scss';
|
|
40
|
+
|
|
41
|
+
// ─── Public types ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
/** Below this viewport width (px), nav collapses to a drawer. @default 768 */
|
|
46
|
+
breakpoint?: number;
|
|
47
|
+
/** Controlled drawer open state (mobile only). */
|
|
48
|
+
drawerOpen?: boolean;
|
|
49
|
+
/** Uncontrolled initial drawer state. @default false */
|
|
50
|
+
defaultDrawerOpen?: boolean;
|
|
51
|
+
/** Called when the drawer opens or closes. */
|
|
52
|
+
onDrawerOpenChange?: (open: boolean) => void;
|
|
53
|
+
/** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
|
|
54
|
+
hideTrigger?: boolean;
|
|
55
|
+
/** Collapse to an icon-only rail: labels hide, icons center, and each row
|
|
56
|
+
* shows a native tooltip from its `tooltip` prop. @default false */
|
|
57
|
+
collapsed?: boolean;
|
|
58
|
+
/** Accessible label for the <nav> landmark. @default 'Main navigation' */
|
|
59
|
+
'aria-label'?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EvoNavGroupProps {
|
|
63
|
+
label: string;
|
|
64
|
+
children: ReactNode;
|
|
65
|
+
className?: string;
|
|
66
|
+
/** Render the heading as a disclosure that expands/collapses the group. */
|
|
67
|
+
collapsible?: boolean;
|
|
68
|
+
/** Uncontrolled initial open state (collapsible only). @default true */
|
|
69
|
+
defaultOpen?: boolean;
|
|
70
|
+
/** Controlled open state (collapsible only). */
|
|
71
|
+
open?: boolean;
|
|
72
|
+
/** Called when the group expands or collapses. */
|
|
73
|
+
onOpenChange?: (open: boolean) => void;
|
|
74
|
+
/** Small count chip shown after the label. */
|
|
75
|
+
count?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface EvoNavRowProps {
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
icon?: ReactNode;
|
|
81
|
+
/** Marks this row as the current page (sets aria-current="page"). */
|
|
82
|
+
active?: boolean;
|
|
83
|
+
/** Render as <a href> instead of <button>. */
|
|
84
|
+
href?: string;
|
|
85
|
+
onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
|
|
86
|
+
/** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
|
|
87
|
+
tooltip?: string;
|
|
88
|
+
/** Controlled expand state (only when row has SubItem children). */
|
|
89
|
+
open?: boolean;
|
|
90
|
+
/** Uncontrolled initial expand state. */
|
|
91
|
+
defaultOpen?: boolean;
|
|
92
|
+
onOpenChange?: (open: boolean) => void;
|
|
93
|
+
disabled?: boolean;
|
|
94
|
+
className?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EvoNavItemProps extends EvoNavRowProps {}
|
|
98
|
+
export interface EvoNavSubItemProps extends EvoNavRowProps {}
|
|
99
|
+
|
|
100
|
+
export interface EvoNavSkeletonProps {
|
|
101
|
+
/** @default 4 */
|
|
102
|
+
count?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EvoNavQuickActionProps
|
|
106
|
+
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
107
|
+
/** @default 'Create New' */
|
|
108
|
+
label?: string;
|
|
109
|
+
icon?: ReactNode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Internal context ──────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
interface NavRootContextValue {
|
|
115
|
+
/** Whether the viewport is below `breakpoint`. */
|
|
116
|
+
isMobile: boolean;
|
|
117
|
+
/** Drawer open state (only meaningful when isMobile). */
|
|
118
|
+
drawerOpen: boolean;
|
|
119
|
+
setDrawerOpen: (open: boolean) => void;
|
|
120
|
+
/** Closes the drawer; safe to call regardless of mobile state. */
|
|
121
|
+
closeDrawer: () => void;
|
|
122
|
+
/** Root id used by the hamburger button's aria-controls. */
|
|
123
|
+
rootId: string;
|
|
124
|
+
/** Icon-only rail mode — labels hide, rows surface a native tooltip. */
|
|
125
|
+
collapsed: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const NavRootContext = createContext<NavRootContextValue | null>(null);
|
|
129
|
+
|
|
130
|
+
interface NavDepthContextValue {
|
|
131
|
+
depth: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const NavDepthContext = createContext<NavDepthContextValue>({ depth: 0 });
|
|
135
|
+
|
|
136
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function useIsBelowWidth(maxWidth: number): boolean {
|
|
139
|
+
const [isBelow, setIsBelow] = useState(false);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
143
|
+
const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
|
|
144
|
+
const update = () => setIsBelow(mql.matches);
|
|
145
|
+
update();
|
|
146
|
+
mql.addEventListener('change', update);
|
|
147
|
+
return () => mql.removeEventListener('change', update);
|
|
148
|
+
}, [maxWidth]);
|
|
149
|
+
|
|
150
|
+
return isBelow;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolve controlled vs. uncontrolled state. */
|
|
154
|
+
function useControllableState<T>(
|
|
155
|
+
controlled: T | undefined,
|
|
156
|
+
defaultValue: T,
|
|
157
|
+
onChange?: (value: T) => void,
|
|
158
|
+
): [T, (value: T) => void] {
|
|
159
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
160
|
+
const isControlled = controlled !== undefined;
|
|
161
|
+
const value = isControlled ? (controlled as T) : uncontrolled;
|
|
162
|
+
const setValue = useCallback(
|
|
163
|
+
(next: T) => {
|
|
164
|
+
if (!isControlled) setUncontrolled(next);
|
|
165
|
+
onChange?.(next);
|
|
166
|
+
},
|
|
167
|
+
[isControlled, onChange],
|
|
168
|
+
);
|
|
169
|
+
return [value, setValue];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Keyboard navigation ──────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const ROW_ATTR = 'data-evo-nav-row';
|
|
175
|
+
|
|
176
|
+
function focusableRows(root: HTMLElement | null): HTMLElement[] {
|
|
177
|
+
if (!root) return [];
|
|
178
|
+
return Array.from(
|
|
179
|
+
root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
|
|
180
|
+
).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
|
|
184
|
+
const rows = focusableRows(root);
|
|
185
|
+
if (rows.length === 0) return;
|
|
186
|
+
const idx = rows.indexOf(from);
|
|
187
|
+
let nextIdx: number;
|
|
188
|
+
if (delta === 'first') nextIdx = 0;
|
|
189
|
+
else if (delta === 'last') nextIdx = rows.length - 1;
|
|
190
|
+
else nextIdx = Math.max(0, Math.min(rows.length - 1, idx + delta));
|
|
191
|
+
rows[nextIdx]?.focus();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Chevron / plus icons ─────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const ChevronIcon = ({ open }: { open: boolean }) => (
|
|
197
|
+
<svg
|
|
198
|
+
viewBox="0 0 16 16"
|
|
199
|
+
fill="none"
|
|
200
|
+
width="12"
|
|
201
|
+
height="12"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
|
|
204
|
+
>
|
|
205
|
+
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
206
|
+
</svg>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const PlusIcon = () => (
|
|
210
|
+
<svg viewBox="0 0 16 16" fill="none" width="14" height="14" aria-hidden="true">
|
|
211
|
+
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
212
|
+
</svg>
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const HamburgerIcon = () => (
|
|
216
|
+
<svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
|
|
217
|
+
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
218
|
+
</svg>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const CloseIcon = () => (
|
|
222
|
+
<svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
|
|
223
|
+
<path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
224
|
+
</svg>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// ─── Row (Item / SubItem shared implementation) ───────────────────────────
|
|
228
|
+
|
|
229
|
+
interface RowInternalProps extends EvoNavRowProps {
|
|
230
|
+
/** Visual tier — affects font weight and density. */
|
|
231
|
+
tier: 'item' | 'subitem';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function splitSubItemChildren(children: ReactNode): { label: ReactNode[]; subs: ReactNode[] } {
|
|
235
|
+
const label: ReactNode[] = [];
|
|
236
|
+
const subs: ReactNode[] = [];
|
|
237
|
+
const walk = (node: ReactNode) => {
|
|
238
|
+
if (Array.isArray(node)) {
|
|
239
|
+
node.forEach(walk);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (isValidElement(node)) {
|
|
243
|
+
const type = node.type as { displayName?: string } | string;
|
|
244
|
+
if (typeof type !== 'string' && type?.displayName === 'EvoNavSubItem') {
|
|
245
|
+
subs.push(node);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
label.push(node);
|
|
250
|
+
};
|
|
251
|
+
walk(children);
|
|
252
|
+
return { label, subs };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
|
|
256
|
+
{
|
|
257
|
+
children,
|
|
258
|
+
icon,
|
|
259
|
+
active = false,
|
|
260
|
+
href,
|
|
261
|
+
onClick,
|
|
262
|
+
tooltip,
|
|
263
|
+
open: openProp,
|
|
264
|
+
defaultOpen = false,
|
|
265
|
+
onOpenChange,
|
|
266
|
+
disabled = false,
|
|
267
|
+
className,
|
|
268
|
+
tier,
|
|
269
|
+
},
|
|
270
|
+
liRef,
|
|
271
|
+
) {
|
|
272
|
+
const rootCtx = useContext(NavRootContext);
|
|
273
|
+
const collapsed = rootCtx?.collapsed ?? false;
|
|
274
|
+
const { depth } = useContext(NavDepthContext);
|
|
275
|
+
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
|
276
|
+
const rowId = useId();
|
|
277
|
+
const subListId = `${rowId}-sub`;
|
|
278
|
+
|
|
279
|
+
const { label, subs } = useMemo(() => splitSubItemChildren(children), [children]);
|
|
280
|
+
const expandable = subs.length > 0;
|
|
281
|
+
const [open, setOpen] = useControllableState(
|
|
282
|
+
expandable ? openProp : false,
|
|
283
|
+
expandable ? defaultOpen : false,
|
|
284
|
+
onOpenChange,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const toggle = useCallback(() => {
|
|
288
|
+
if (!expandable) return;
|
|
289
|
+
setOpen(!open);
|
|
290
|
+
}, [expandable, open, setOpen]);
|
|
291
|
+
|
|
292
|
+
const handleActivate = useCallback(
|
|
293
|
+
(event: ReactMouseEvent | ReactKeyboardEvent) => {
|
|
294
|
+
if (disabled) return;
|
|
295
|
+
if (expandable && !href) {
|
|
296
|
+
// No real navigation target — primary click toggles disclosure.
|
|
297
|
+
toggle();
|
|
298
|
+
}
|
|
299
|
+
onClick?.(event);
|
|
300
|
+
// On mobile, navigating closes the drawer so the user lands on the page.
|
|
301
|
+
if (rootCtx?.isMobile && href && !expandable) {
|
|
302
|
+
rootCtx.closeDrawer();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
[disabled, expandable, href, onClick, rootCtx, toggle],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const handleKeyDown = (event: ReactKeyboardEvent) => {
|
|
309
|
+
if (disabled) return;
|
|
310
|
+
const self = event.currentTarget as HTMLElement;
|
|
311
|
+
const navRoot = self.closest<HTMLElement>(`.${styles.navContainer}`);
|
|
312
|
+
switch (event.key) {
|
|
313
|
+
case 'ArrowDown':
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
moveFocus(navRoot, self, 1);
|
|
316
|
+
break;
|
|
317
|
+
case 'ArrowUp':
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
moveFocus(navRoot, self, -1);
|
|
320
|
+
break;
|
|
321
|
+
case 'ArrowRight':
|
|
322
|
+
if (expandable) {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
if (!open) {
|
|
325
|
+
setOpen(true);
|
|
326
|
+
} else {
|
|
327
|
+
// Focus first child row.
|
|
328
|
+
const li = self.closest('li');
|
|
329
|
+
const firstChild = li?.querySelector<HTMLElement>(`ul [${ROW_ATTR}]`);
|
|
330
|
+
firstChild?.focus();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case 'ArrowLeft':
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
if (expandable && open) {
|
|
337
|
+
setOpen(false);
|
|
338
|
+
} else if (depth > 0) {
|
|
339
|
+
// Walk up to the nearest ancestor row.
|
|
340
|
+
const parentLi = self.closest('li')?.parentElement?.closest('li');
|
|
341
|
+
parentLi?.querySelector<HTMLElement>(`[${ROW_ATTR}]`)?.focus();
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
case 'Home':
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
moveFocus(navRoot, self, 'first');
|
|
347
|
+
break;
|
|
348
|
+
case 'End':
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
moveFocus(navRoot, self, 'last');
|
|
351
|
+
break;
|
|
352
|
+
case 'Enter':
|
|
353
|
+
case ' ': {
|
|
354
|
+
// <a> already activates on Enter; let it through but capture Space.
|
|
355
|
+
if (event.key === ' ' || href === undefined) {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
handleActivate(event);
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const rowClasses = [
|
|
367
|
+
styles.navRow,
|
|
368
|
+
tier === 'subitem' ? styles.navRowSub : styles.navRowTop,
|
|
369
|
+
active ? styles.active : '',
|
|
370
|
+
disabled ? styles.disabled : '',
|
|
371
|
+
className,
|
|
372
|
+
]
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.join(' ');
|
|
375
|
+
|
|
376
|
+
const rowStyle =
|
|
377
|
+
tier === 'subitem' && depth > 0
|
|
378
|
+
? ({ ['--evo-nav-indent' as string]: `${depth * 0.875}rem` } as CSSProperties)
|
|
379
|
+
: undefined;
|
|
380
|
+
|
|
381
|
+
const commonRowProps = {
|
|
382
|
+
'data-evo-nav-row': '',
|
|
383
|
+
id: rowId,
|
|
384
|
+
className: rowClasses,
|
|
385
|
+
style: rowStyle,
|
|
386
|
+
title: collapsed && tooltip ? tooltip : undefined,
|
|
387
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
388
|
+
'aria-expanded': expandable ? open : undefined,
|
|
389
|
+
'aria-controls': expandable ? subListId : undefined,
|
|
390
|
+
'aria-disabled': disabled || undefined,
|
|
391
|
+
'data-disabled': disabled ? 'true' : undefined,
|
|
392
|
+
'data-active': active ? 'true' : undefined,
|
|
393
|
+
tabIndex: disabled ? -1 : 0,
|
|
394
|
+
onKeyDown: handleKeyDown,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const rowInner = (
|
|
398
|
+
<>
|
|
399
|
+
{icon && <span className={styles.navIcon}>{icon}</span>}
|
|
400
|
+
<span className={styles.navLabel}>{label}</span>
|
|
401
|
+
{expandable && <ChevronIcon open={open} />}
|
|
402
|
+
</>
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
let rowEl: ReactNode;
|
|
406
|
+
if (href && !disabled) {
|
|
407
|
+
const anchorRest: AnchorHTMLAttributes<HTMLAnchorElement> = {
|
|
408
|
+
href,
|
|
409
|
+
onClick: handleActivate as unknown as AnchorHTMLAttributes<HTMLAnchorElement>['onClick'],
|
|
410
|
+
};
|
|
411
|
+
rowEl = (
|
|
412
|
+
<a
|
|
413
|
+
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
|
414
|
+
{...commonRowProps}
|
|
415
|
+
{...anchorRest}
|
|
416
|
+
>
|
|
417
|
+
{rowInner}
|
|
418
|
+
</a>
|
|
419
|
+
);
|
|
420
|
+
} else {
|
|
421
|
+
rowEl = (
|
|
422
|
+
<button
|
|
423
|
+
ref={buttonRef as RefObject<HTMLButtonElement>}
|
|
424
|
+
type="button"
|
|
425
|
+
disabled={disabled}
|
|
426
|
+
onClick={handleActivate}
|
|
427
|
+
{...commonRowProps}
|
|
428
|
+
>
|
|
429
|
+
{rowInner}
|
|
430
|
+
</button>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<li ref={liRef} className={styles.navLi}>
|
|
436
|
+
{rowEl}
|
|
437
|
+
{expandable && (
|
|
438
|
+
<NavDepthContext.Provider value={{ depth: depth + 1 }}>
|
|
439
|
+
<ul
|
|
440
|
+
id={subListId}
|
|
441
|
+
role="group"
|
|
442
|
+
aria-labelledby={rowId}
|
|
443
|
+
hidden={!open}
|
|
444
|
+
className={styles.navSubList}
|
|
445
|
+
>
|
|
446
|
+
{subs}
|
|
447
|
+
</ul>
|
|
448
|
+
</NavDepthContext.Provider>
|
|
449
|
+
)}
|
|
450
|
+
</li>
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ─── Public sub-components ────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
export const EvoNavItem = forwardRef<HTMLLIElement, EvoNavItemProps>(function EvoNavItem(
|
|
457
|
+
props,
|
|
458
|
+
ref,
|
|
459
|
+
) {
|
|
460
|
+
return <NavRow ref={ref} tier="item" {...props} />;
|
|
461
|
+
});
|
|
462
|
+
EvoNavItem.displayName = 'EvoNavItem';
|
|
463
|
+
|
|
464
|
+
export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(function EvoNavSubItem(
|
|
465
|
+
props,
|
|
466
|
+
ref,
|
|
467
|
+
) {
|
|
468
|
+
return <NavRow ref={ref} tier="subitem" {...props} />;
|
|
469
|
+
});
|
|
470
|
+
EvoNavSubItem.displayName = 'EvoNavSubItem';
|
|
471
|
+
|
|
472
|
+
export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
|
|
473
|
+
{ label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
|
|
474
|
+
ref,
|
|
475
|
+
) {
|
|
476
|
+
const headingId = useId();
|
|
477
|
+
const panelId = `${headingId}-panel`;
|
|
478
|
+
const collapsed = useContext(NavRootContext)?.collapsed ?? false;
|
|
479
|
+
// The disclosure is interactive only when collapsible AND not in icon-rail
|
|
480
|
+
// mode. In the rail the accordion is meaningless, so we render a static
|
|
481
|
+
// heading and show the items — avoiding a focusable, label-less toggle that
|
|
482
|
+
// would silently mutate the (forced-open) state.
|
|
483
|
+
const interactive = collapsible && !collapsed;
|
|
484
|
+
const [open, setOpen] = useControllableState(
|
|
485
|
+
interactive ? openProp : true,
|
|
486
|
+
interactive ? defaultOpen : true,
|
|
487
|
+
onOpenChange,
|
|
488
|
+
);
|
|
489
|
+
const effectiveOpen = collapsed ? true : open;
|
|
490
|
+
|
|
491
|
+
// Toggle `inert` imperatively rather than through a prop: `inert` is only a
|
|
492
|
+
// managed React attribute as of React 19, but the peer range allows >=17.
|
|
493
|
+
// The DOM API works on every version and keeps the console warning-free.
|
|
494
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
const el = panelRef.current;
|
|
497
|
+
if (!el) return;
|
|
498
|
+
if (effectiveOpen) el.removeAttribute('inert');
|
|
499
|
+
else el.setAttribute('inert', '');
|
|
500
|
+
}, [effectiveOpen]);
|
|
501
|
+
|
|
502
|
+
const countChip =
|
|
503
|
+
count != null ? (
|
|
504
|
+
<span className={styles.navGroupCount} aria-hidden="true">
|
|
505
|
+
{count}
|
|
506
|
+
</span>
|
|
507
|
+
) : null;
|
|
508
|
+
|
|
509
|
+
const list = (
|
|
510
|
+
<ul role="group" aria-labelledby={headingId} className={styles.navList}>
|
|
511
|
+
{children}
|
|
512
|
+
</ul>
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<li ref={ref} className={[styles.navGroup, className].filter(Boolean).join(' ')}>
|
|
517
|
+
{interactive ? (
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
id={headingId}
|
|
521
|
+
className={[styles.navGroupLabel, styles.navGroupToggle].join(' ')}
|
|
522
|
+
aria-expanded={effectiveOpen}
|
|
523
|
+
aria-controls={panelId}
|
|
524
|
+
onClick={() => setOpen(!open)}
|
|
525
|
+
>
|
|
526
|
+
<span className={styles.navGroupLabelText}>{label}</span>
|
|
527
|
+
{countChip}
|
|
528
|
+
<ChevronIcon open={effectiveOpen} />
|
|
529
|
+
</button>
|
|
530
|
+
) : (
|
|
531
|
+
<div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
|
|
532
|
+
<span className={styles.navGroupLabelText}>{label}</span>
|
|
533
|
+
{countChip}
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
{interactive ? (
|
|
537
|
+
<div
|
|
538
|
+
ref={panelRef}
|
|
539
|
+
id={panelId}
|
|
540
|
+
className={styles.navGroupPanel}
|
|
541
|
+
data-open={effectiveOpen ? 'true' : 'false'}
|
|
542
|
+
>
|
|
543
|
+
{list}
|
|
544
|
+
</div>
|
|
545
|
+
) : (
|
|
546
|
+
list
|
|
547
|
+
)}
|
|
548
|
+
</li>
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
EvoNavGroup.displayName = 'EvoNavGroup';
|
|
552
|
+
|
|
553
|
+
export const EvoNavSkeleton = ({ count = 4 }: EvoNavSkeletonProps) => (
|
|
554
|
+
<>
|
|
555
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
556
|
+
<li
|
|
557
|
+
key={i}
|
|
558
|
+
aria-hidden="true"
|
|
559
|
+
className={styles.navSkeletonItem}
|
|
560
|
+
>
|
|
561
|
+
<span className={styles.navSkeletonIcon} />
|
|
562
|
+
<span
|
|
563
|
+
className={styles.navSkeletonText}
|
|
564
|
+
style={{ width: `${45 + (i % 4) * 12}%` }}
|
|
565
|
+
/>
|
|
566
|
+
</li>
|
|
567
|
+
))}
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
export const EvoNavQuickAction = forwardRef<HTMLButtonElement, EvoNavQuickActionProps>(
|
|
572
|
+
function EvoNavQuickAction({ label = 'Create New', icon, className, type = 'button', ...rest }, ref) {
|
|
573
|
+
return (
|
|
574
|
+
<li className={styles.navLi}>
|
|
575
|
+
<button
|
|
576
|
+
ref={ref}
|
|
577
|
+
type={type}
|
|
578
|
+
className={[styles.navQuickAction, className].filter(Boolean).join(' ')}
|
|
579
|
+
{...rest}
|
|
580
|
+
>
|
|
581
|
+
<span className={styles.navIcon}>{icon ?? <PlusIcon />}</span>
|
|
582
|
+
<span className={styles.navLabel}>{label}</span>
|
|
583
|
+
</button>
|
|
584
|
+
</li>
|
|
585
|
+
);
|
|
586
|
+
},
|
|
587
|
+
);
|
|
588
|
+
EvoNavQuickAction.displayName = 'EvoNavQuickAction';
|
|
589
|
+
|
|
590
|
+
// ─── Root ─────────────────────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
|
|
593
|
+
{
|
|
594
|
+
children,
|
|
595
|
+
breakpoint = 768,
|
|
596
|
+
drawerOpen: drawerOpenProp,
|
|
597
|
+
defaultDrawerOpen = false,
|
|
598
|
+
onDrawerOpenChange,
|
|
599
|
+
hideTrigger = false,
|
|
600
|
+
collapsed = false,
|
|
601
|
+
className,
|
|
602
|
+
'aria-label': ariaLabel = 'Main navigation',
|
|
603
|
+
...rest
|
|
604
|
+
},
|
|
605
|
+
ref,
|
|
606
|
+
) {
|
|
607
|
+
const isMobile = useIsBelowWidth(breakpoint);
|
|
608
|
+
const [drawerOpen, setDrawerOpen] = useControllableState(
|
|
609
|
+
drawerOpenProp,
|
|
610
|
+
defaultDrawerOpen,
|
|
611
|
+
onDrawerOpenChange,
|
|
612
|
+
);
|
|
613
|
+
const closeDrawer = useCallback(() => setDrawerOpen(false), [setDrawerOpen]);
|
|
614
|
+
const rootId = useId();
|
|
615
|
+
const navRef = useRef<HTMLElement>(null);
|
|
616
|
+
|
|
617
|
+
// Close drawer on Escape when on mobile.
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
if (!isMobile || !drawerOpen) return;
|
|
620
|
+
const handler = (e: globalThis.KeyboardEvent) => {
|
|
621
|
+
if (e.key === 'Escape') closeDrawer();
|
|
622
|
+
};
|
|
623
|
+
document.addEventListener('keydown', handler);
|
|
624
|
+
return () => document.removeEventListener('keydown', handler);
|
|
625
|
+
}, [isMobile, drawerOpen, closeDrawer]);
|
|
626
|
+
|
|
627
|
+
// Lock body scroll when drawer is open.
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
if (!isMobile || !drawerOpen) return;
|
|
630
|
+
const prev = document.body.style.overflow;
|
|
631
|
+
document.body.style.overflow = 'hidden';
|
|
632
|
+
return () => {
|
|
633
|
+
document.body.style.overflow = prev;
|
|
634
|
+
};
|
|
635
|
+
}, [isMobile, drawerOpen]);
|
|
636
|
+
|
|
637
|
+
// When transitioning back to desktop width, ensure drawer is closed so
|
|
638
|
+
// subsequent shrinks reopen the sidebar cleanly.
|
|
639
|
+
useEffect(() => {
|
|
640
|
+
if (!isMobile && drawerOpen) closeDrawer();
|
|
641
|
+
}, [isMobile, drawerOpen, closeDrawer]);
|
|
642
|
+
|
|
643
|
+
const ctxValue = useMemo<NavRootContextValue>(
|
|
644
|
+
() => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
|
|
645
|
+
[isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const navClasses = [
|
|
649
|
+
styles.navContainer,
|
|
650
|
+
isMobile ? styles.navMobile : styles.navDesktop,
|
|
651
|
+
isMobile && drawerOpen ? styles.navDrawerOpen : '',
|
|
652
|
+
collapsed ? styles.navCollapsed : '',
|
|
653
|
+
className,
|
|
654
|
+
]
|
|
655
|
+
.filter(Boolean)
|
|
656
|
+
.join(' ');
|
|
657
|
+
|
|
658
|
+
const setRefs = (el: HTMLElement | null) => {
|
|
659
|
+
(navRef as RefObject<HTMLElement | null>).current = el;
|
|
660
|
+
if (typeof ref === 'function') ref(el);
|
|
661
|
+
else if (ref) (ref as RefObject<HTMLElement | null>).current = el;
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
return (
|
|
665
|
+
<NavRootContext.Provider value={ctxValue}>
|
|
666
|
+
{isMobile && !hideTrigger && (
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
className={styles.navTrigger}
|
|
670
|
+
aria-expanded={drawerOpen}
|
|
671
|
+
aria-controls={rootId}
|
|
672
|
+
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
|
|
673
|
+
onClick={() => setDrawerOpen(!drawerOpen)}
|
|
674
|
+
>
|
|
675
|
+
{drawerOpen ? <CloseIcon /> : <HamburgerIcon />}
|
|
676
|
+
</button>
|
|
677
|
+
)}
|
|
678
|
+
{isMobile && drawerOpen && (
|
|
679
|
+
<div
|
|
680
|
+
className={styles.navBackdrop}
|
|
681
|
+
aria-hidden="true"
|
|
682
|
+
onClick={closeDrawer}
|
|
683
|
+
/>
|
|
684
|
+
)}
|
|
685
|
+
<nav
|
|
686
|
+
ref={setRefs}
|
|
687
|
+
id={rootId}
|
|
688
|
+
role="navigation"
|
|
689
|
+
aria-label={ariaLabel}
|
|
690
|
+
aria-hidden={isMobile && !drawerOpen ? true : undefined}
|
|
691
|
+
className={navClasses}
|
|
692
|
+
{...rest}
|
|
693
|
+
>
|
|
694
|
+
<ul className={styles.navList}>{children}</ul>
|
|
695
|
+
</nav>
|
|
696
|
+
</NavRootContext.Provider>
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
EvoNavRoot.displayName = 'EvoNav';
|
|
701
|
+
|
|
702
|
+
export const EvoNav = Object.assign(EvoNavRoot, {
|
|
703
|
+
Group: EvoNavGroup,
|
|
704
|
+
Item: EvoNavItem,
|
|
705
|
+
SubItem: EvoNavSubItem,
|
|
706
|
+
Skeleton: EvoNavSkeleton,
|
|
707
|
+
QuickAction: EvoNavQuickAction,
|
|
708
|
+
});
|