@justin_evo/evo-ui 1.0.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/dist/Alert/Alert.d.ts +11 -0
- package/dist/AutoComplete/AutoComplete.d.ts +95 -0
- package/dist/Badge/Badge.d.ts +23 -0
- package/dist/Breadcrumb/Breadcrumb.d.ts +16 -0
- package/dist/Button/Button.d.ts +54 -0
- package/dist/Card/Card.d.ts +60 -0
- package/dist/Checkbox/Checkbox.d.ts +16 -0
- package/dist/CommandPalette/CommandPalette.d.ts +17 -0
- package/dist/Container/Container.d.ts +10 -0
- package/dist/Divider/Divider.d.ts +7 -0
- package/dist/Form/Form.d.ts +61 -0
- package/dist/Grid/Grid.d.ts +23 -0
- package/dist/ImageCropper/ImageCropper.d.ts +111 -0
- package/dist/Input/Input.d.ts +12 -0
- package/dist/Modal/Modal.d.ts +26 -0
- package/dist/Nav/Nav.d.ts +63 -0
- package/dist/Notification/Notification.d.ts +186 -0
- package/dist/Pagination/Pagination.d.ts +10 -0
- package/dist/Radio/Radio.d.ts +20 -0
- package/dist/RichTextArea/RichTextArea.d.ts +70 -0
- package/dist/Select/Select.d.ts +44 -0
- package/dist/Skeleton/Skeleton.d.ts +23 -0
- package/dist/Stack/Stack.d.ts +16 -0
- package/dist/Table/Table.d.ts +77 -0
- package/dist/Tabs/Tabs.d.ts +28 -0
- package/dist/Theme/ThemeProvider.d.ts +96 -0
- package/dist/Theme/ThemeToggle.d.ts +22 -0
- package/dist/Toggle/Toggle.d.ts +11 -0
- package/dist/Tooltip/Tooltip.d.ts +10 -0
- package/dist/TopNav/TopNav.d.ts +76 -0
- package/dist/TreeSelect/TreeSelect.d.ts +50 -0
- package/dist/declarations.d.ts +6 -0
- package/dist/evo-ui.css +1 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.es.js +5688 -0
- package/package.json +52 -0
- package/src/Alert/Alert.tsx +49 -0
- package/src/AutoComplete/AutoComplete.tsx +810 -0
- package/src/Badge/Badge.tsx +53 -0
- package/src/Breadcrumb/Breadcrumb.tsx +53 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.tsx +257 -0
- package/src/Checkbox/Checkbox.tsx +59 -0
- package/src/CommandPalette/CommandPalette.tsx +185 -0
- package/src/Container/Container.tsx +31 -0
- package/src/Divider/Divider.tsx +31 -0
- package/src/Form/Form.tsx +185 -0
- package/src/Grid/Grid.tsx +66 -0
- package/src/ImageCropper/ImageCropper.tsx +911 -0
- package/src/Input/Input.tsx +74 -0
- package/src/Modal/Modal.tsx +77 -0
- package/src/Nav/Nav.tsx +626 -0
- package/src/Notification/Notification.tsx +1503 -0
- package/src/Pagination/Pagination.tsx +76 -0
- package/src/Radio/Radio.tsx +69 -0
- package/src/RichTextArea/RichTextArea.tsx +869 -0
- package/src/Select/Select.tsx +515 -0
- package/src/Skeleton/Skeleton.tsx +70 -0
- package/src/Stack/Stack.tsx +52 -0
- package/src/Table/Table.tsx +335 -0
- package/src/Tabs/Tabs.tsx +90 -0
- package/src/Theme/ThemeProvider.tsx +253 -0
- package/src/Theme/ThemeToggle.tsx +79 -0
- package/src/Toggle/Toggle.tsx +48 -0
- package/src/Tooltip/Tooltip.tsx +38 -0
- package/src/TopNav/TopNav.tsx +994 -0
- package/src/TreeSelect/TreeSelect.tsx +825 -0
- package/src/css/alert.module.scss +93 -0
- package/src/css/autocomplete.module.scss +416 -0
- package/src/css/badge.module.scss +82 -0
- package/src/css/base/_color.scss +159 -0
- package/src/css/base/_theme.scss +237 -0
- package/src/css/base/_variables.scss +161 -0
- package/src/css/breadcrumb.module.scss +50 -0
- package/src/css/button.module.scss +385 -0
- package/src/css/card.module.scss +217 -0
- package/src/css/checkbox.module.scss +120 -0
- package/src/css/commandpalette.module.scss +211 -0
- package/src/css/container.module.scss +18 -0
- package/src/css/divider.module.scss +41 -0
- package/src/css/form.module.scss +245 -0
- package/src/css/imagecropper.module.scss +397 -0
- package/src/css/input.module.scss +89 -0
- package/src/css/modal.module.scss +105 -0
- package/src/css/nav.module.scss +339 -0
- package/src/css/notification.module.scss +691 -0
- package/src/css/pagination.module.scss +63 -0
- package/src/css/radio.module.scss +89 -0
- package/src/css/richtextarea.module.scss +307 -0
- package/src/css/select.module.scss +525 -0
- package/src/css/skeleton.module.scss +30 -0
- package/src/css/table.module.scss +386 -0
- package/src/css/tabs.module.scss +63 -0
- package/src/css/theme-toggle.module.scss +83 -0
- package/src/css/toggle.module.scss +54 -0
- package/src/css/tooltip.module.scss +97 -0
- package/src/css/topnav.module.scss +396 -0
- package/src/css/treeselect.module.scss +558 -0
- package/src/css/utilities/_borders.scss +111 -0
- package/src/css/utilities/_colors.scss +66 -0
- package/src/css/utilities/_effects.scss +216 -0
- package/src/css/utilities/_layout.scss +181 -0
- package/src/css/utilities/_position.scss +75 -0
- package/src/css/utilities/_sizing.scss +138 -0
- package/src/css/utilities/_spacing.scss +99 -0
- package/src/css/utilities/_typography.scss +121 -0
- package/src/css/utilities/index.scss +24 -0
- package/src/declarations.d.ts +6 -0
- package/src/index.ts +60 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// EvoTopNav — compose-based top navigation with a mobile drawer, polymorphic
|
|
2
|
+
// items, and dropdown sub-menus.
|
|
3
|
+
//
|
|
4
|
+
// Research notes (CLAUDE.md §2):
|
|
5
|
+
// - Radix Navigation Menu — adopted the compose + `asChild` slot pattern.
|
|
6
|
+
// - Mantine AppShell.Header — kept Burger / drawer as a separate sub-component
|
|
7
|
+
// so the header itself stays a thin layout primitive.
|
|
8
|
+
// - shadcn Navigation Menu — confirmed the same compose shape works without
|
|
9
|
+
// any runtime dependency.
|
|
10
|
+
|
|
11
|
+
import React, {
|
|
12
|
+
forwardRef,
|
|
13
|
+
useCallback,
|
|
14
|
+
useEffect,
|
|
15
|
+
useId,
|
|
16
|
+
useLayoutEffect,
|
|
17
|
+
useMemo,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
} from 'react';
|
|
21
|
+
import styles from '../css/topnav.module.scss';
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export interface EvoTopNavProps
|
|
28
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> {
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
open?: boolean;
|
|
31
|
+
defaultOpen?: boolean;
|
|
32
|
+
onOpenChange?: (open: boolean) => void;
|
|
33
|
+
/** Width in px below which Menu collapses into the drawer. @default 768 */
|
|
34
|
+
collapseBelow?: number;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EvoTopNavBrandProps
|
|
39
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EvoTopNavMenuProps
|
|
45
|
+
extends React.HTMLAttributes<HTMLUListElement> {
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface EvoTopNavItemProps {
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
active?: boolean;
|
|
53
|
+
icon?: React.ReactNode;
|
|
54
|
+
href?: string;
|
|
55
|
+
target?: '_self' | '_blank' | '_parent' | '_top';
|
|
56
|
+
rel?: string;
|
|
57
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
58
|
+
asChild?: boolean;
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EvoTopNavActionsProps
|
|
63
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
64
|
+
children: React.ReactNode;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EvoTopNavToggleProps
|
|
69
|
+
extends Omit<
|
|
70
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
71
|
+
'aria-expanded' | 'aria-controls'
|
|
72
|
+
> {
|
|
73
|
+
icon?: React.ReactNode;
|
|
74
|
+
className?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EvoTopNavDropdownProps {
|
|
78
|
+
label: React.ReactNode;
|
|
79
|
+
icon?: React.ReactNode;
|
|
80
|
+
active?: boolean;
|
|
81
|
+
hoverable?: boolean;
|
|
82
|
+
open?: boolean;
|
|
83
|
+
defaultOpen?: boolean;
|
|
84
|
+
onOpenChange?: (open: boolean) => void;
|
|
85
|
+
children: React.ReactNode;
|
|
86
|
+
className?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface EvoTopNavDropdownItemProps {
|
|
90
|
+
children: React.ReactNode;
|
|
91
|
+
icon?: React.ReactNode;
|
|
92
|
+
active?: boolean;
|
|
93
|
+
href?: string;
|
|
94
|
+
target?: '_self' | '_blank' | '_parent' | '_top';
|
|
95
|
+
rel?: string;
|
|
96
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
97
|
+
asChild?: boolean;
|
|
98
|
+
className?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Internal helpers
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
const cn = (...c: Array<string | false | null | undefined>) =>
|
|
106
|
+
c.filter(Boolean).join(' ');
|
|
107
|
+
|
|
108
|
+
function composeHandlers<E extends React.SyntheticEvent>(
|
|
109
|
+
ours?: (e: E) => void,
|
|
110
|
+
theirs?: (e: E) => void,
|
|
111
|
+
) {
|
|
112
|
+
return (e: E) => {
|
|
113
|
+
ours?.(e);
|
|
114
|
+
if (!e.defaultPrevented) theirs?.(e);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function useControllableState<T>(opts: {
|
|
119
|
+
value?: T;
|
|
120
|
+
defaultValue: T;
|
|
121
|
+
onChange?: (v: T) => void;
|
|
122
|
+
}) {
|
|
123
|
+
const { value, defaultValue, onChange } = opts;
|
|
124
|
+
const [internal, setInternal] = useState<T>(defaultValue);
|
|
125
|
+
const isControlled = value !== undefined;
|
|
126
|
+
const current = isControlled ? (value as T) : internal;
|
|
127
|
+
const setValue = useCallback(
|
|
128
|
+
(next: T) => {
|
|
129
|
+
if (!isControlled) setInternal(next);
|
|
130
|
+
onChange?.(next);
|
|
131
|
+
},
|
|
132
|
+
[isControlled, onChange],
|
|
133
|
+
);
|
|
134
|
+
return [current, setValue] as const;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const useIsoLayoutEffect =
|
|
138
|
+
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
139
|
+
|
|
140
|
+
function useMediaQuery(query: string, fallback = false) {
|
|
141
|
+
const [matches, setMatches] = useState(fallback);
|
|
142
|
+
useIsoLayoutEffect(() => {
|
|
143
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
144
|
+
const mql = window.matchMedia(query);
|
|
145
|
+
const update = () => setMatches(mql.matches);
|
|
146
|
+
update();
|
|
147
|
+
mql.addEventListener?.('change', update);
|
|
148
|
+
return () => mql.removeEventListener?.('change', update);
|
|
149
|
+
}, [query]);
|
|
150
|
+
return matches;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const useIsCollapsed = (breakpoint: number) =>
|
|
154
|
+
useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
|
|
155
|
+
|
|
156
|
+
const useHoverCapable = () =>
|
|
157
|
+
useMediaQuery('(hover: hover) and (pointer: fine)', true);
|
|
158
|
+
|
|
159
|
+
const usePrefersReducedMotion = () =>
|
|
160
|
+
useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
161
|
+
|
|
162
|
+
function getFocusable(root: HTMLElement | null) {
|
|
163
|
+
if (!root) return [] as HTMLElement[];
|
|
164
|
+
return Array.from(
|
|
165
|
+
root.querySelectorAll<HTMLElement>(
|
|
166
|
+
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])',
|
|
167
|
+
),
|
|
168
|
+
).filter(
|
|
169
|
+
(el) =>
|
|
170
|
+
!el.hasAttribute('aria-hidden') &&
|
|
171
|
+
el.offsetParent !== null,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Slot — minimal Radix-style asChild implementation (single child)
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
interface SlotProps extends React.HTMLAttributes<HTMLElement> {
|
|
180
|
+
children: React.ReactNode;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
|
|
184
|
+
{ children, ...slotProps },
|
|
185
|
+
_ref,
|
|
186
|
+
) {
|
|
187
|
+
const child = React.Children.only(children) as React.ReactElement<
|
|
188
|
+
Record<string, unknown>
|
|
189
|
+
>;
|
|
190
|
+
const childProps = (child.props ?? {}) as Record<string, unknown>;
|
|
191
|
+
|
|
192
|
+
const merged: Record<string, unknown> = { ...slotProps, ...childProps };
|
|
193
|
+
|
|
194
|
+
// className: parent first, child appended
|
|
195
|
+
merged.className = cn(
|
|
196
|
+
slotProps.className as string | undefined,
|
|
197
|
+
childProps.className as string | undefined,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// style: shallow merge — child wins on key collisions
|
|
201
|
+
if (slotProps.style || childProps.style) {
|
|
202
|
+
merged.style = {
|
|
203
|
+
...(slotProps.style as React.CSSProperties),
|
|
204
|
+
...(childProps.style as React.CSSProperties),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Compose event handlers (every on* prop on the slot)
|
|
209
|
+
for (const key of Object.keys(slotProps)) {
|
|
210
|
+
if (
|
|
211
|
+
key.startsWith('on') &&
|
|
212
|
+
typeof (slotProps as Record<string, unknown>)[key] === 'function'
|
|
213
|
+
) {
|
|
214
|
+
const ours = (slotProps as Record<string, unknown>)[key] as (
|
|
215
|
+
e: React.SyntheticEvent,
|
|
216
|
+
) => void;
|
|
217
|
+
const theirs = childProps[key] as
|
|
218
|
+
| ((e: React.SyntheticEvent) => void)
|
|
219
|
+
| undefined;
|
|
220
|
+
merged[key] = composeHandlers(ours, theirs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return React.cloneElement(child, merged);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Context
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
interface TopNavContextValue {
|
|
232
|
+
drawerOpen: boolean;
|
|
233
|
+
setDrawerOpen: (open: boolean) => void;
|
|
234
|
+
isCollapsed: boolean;
|
|
235
|
+
menuId: string;
|
|
236
|
+
toggleId: string;
|
|
237
|
+
registerToggle: () => () => void;
|
|
238
|
+
toggleCount: number;
|
|
239
|
+
menuRef: React.RefObject<HTMLUListElement | null>;
|
|
240
|
+
toggleRef: React.RefObject<HTMLButtonElement | null>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const TopNavContext = React.createContext<TopNavContextValue | null>(null);
|
|
244
|
+
|
|
245
|
+
function useTopNavContext(component: string) {
|
|
246
|
+
const ctx = React.useContext(TopNavContext);
|
|
247
|
+
if (!ctx) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`EvoTopNav.${component} must be rendered inside <EvoTopNav>.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return ctx;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface DropdownContextValue {
|
|
256
|
+
open: boolean;
|
|
257
|
+
setOpen: (open: boolean) => void;
|
|
258
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
|
259
|
+
contentRef: React.RefObject<HTMLUListElement | null>;
|
|
260
|
+
contentId: string;
|
|
261
|
+
focusItem: (delta: 1 | -1 | 'first' | 'last') => void;
|
|
262
|
+
inDrawer: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// Default icons
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
const HamburgerIcon = ({ open }: { open: boolean }) => (
|
|
272
|
+
<svg
|
|
273
|
+
width="18"
|
|
274
|
+
height="18"
|
|
275
|
+
viewBox="0 0 18 18"
|
|
276
|
+
fill="none"
|
|
277
|
+
aria-hidden="true"
|
|
278
|
+
className={styles.toggleIcon}
|
|
279
|
+
>
|
|
280
|
+
{open ? (
|
|
281
|
+
<>
|
|
282
|
+
<path
|
|
283
|
+
d="M4 4l10 10M14 4L4 14"
|
|
284
|
+
stroke="currentColor"
|
|
285
|
+
strokeWidth="1.75"
|
|
286
|
+
strokeLinecap="round"
|
|
287
|
+
/>
|
|
288
|
+
</>
|
|
289
|
+
) : (
|
|
290
|
+
<>
|
|
291
|
+
<path
|
|
292
|
+
d="M3 5h12M3 9h12M3 13h12"
|
|
293
|
+
stroke="currentColor"
|
|
294
|
+
strokeWidth="1.75"
|
|
295
|
+
strokeLinecap="round"
|
|
296
|
+
/>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</svg>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const ChevronIcon = ({ open }: { open: boolean }) => (
|
|
303
|
+
<svg
|
|
304
|
+
viewBox="0 0 12 12"
|
|
305
|
+
width="10"
|
|
306
|
+
height="10"
|
|
307
|
+
fill="none"
|
|
308
|
+
aria-hidden="true"
|
|
309
|
+
className={cn(styles.dropdownChevron, open && styles.dropdownChevronOpen)}
|
|
310
|
+
>
|
|
311
|
+
<path
|
|
312
|
+
d="M3 4.5l3 3 3-3"
|
|
313
|
+
stroke="currentColor"
|
|
314
|
+
strokeWidth="1.5"
|
|
315
|
+
strokeLinecap="round"
|
|
316
|
+
strokeLinejoin="round"
|
|
317
|
+
/>
|
|
318
|
+
</svg>
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// ============================================================================
|
|
322
|
+
// EvoTopNav.Brand
|
|
323
|
+
// ============================================================================
|
|
324
|
+
|
|
325
|
+
const EvoTopNavBrand = forwardRef<HTMLDivElement, EvoTopNavBrandProps>(
|
|
326
|
+
function EvoTopNavBrand({ children, className, ...rest }, ref) {
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
ref={ref}
|
|
330
|
+
className={cn(styles.topNavBrand, className)}
|
|
331
|
+
{...rest}
|
|
332
|
+
>
|
|
333
|
+
{children}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// EvoTopNav.Menu
|
|
341
|
+
// ============================================================================
|
|
342
|
+
|
|
343
|
+
const EvoTopNavMenu = forwardRef<HTMLUListElement, EvoTopNavMenuProps>(
|
|
344
|
+
function EvoTopNavMenu({ children, className, ...rest }, forwardedRef) {
|
|
345
|
+
const ctx = useTopNavContext('Menu');
|
|
346
|
+
const localRef = useRef<HTMLUListElement | null>(null);
|
|
347
|
+
|
|
348
|
+
const setRef = (node: HTMLUListElement | null) => {
|
|
349
|
+
localRef.current = node;
|
|
350
|
+
ctx.menuRef.current = node;
|
|
351
|
+
if (typeof forwardedRef === 'function') forwardedRef(node);
|
|
352
|
+
else if (forwardedRef)
|
|
353
|
+
(forwardedRef as React.RefObject<HTMLUListElement | null>).current = node;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const hasToggle = ctx.toggleCount > 0;
|
|
357
|
+
const inDrawer = ctx.isCollapsed && hasToggle;
|
|
358
|
+
const drawerClosed = inDrawer && !ctx.drawerOpen;
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<ul
|
|
362
|
+
ref={setRef}
|
|
363
|
+
id={ctx.menuId}
|
|
364
|
+
className={cn(
|
|
365
|
+
styles.topNavMenu,
|
|
366
|
+
inDrawer && styles.topNavMenuDrawer,
|
|
367
|
+
drawerClosed && styles.topNavMenuDrawerClosed,
|
|
368
|
+
ctx.isCollapsed && !hasToggle && styles.topNavMenuScroll,
|
|
369
|
+
className,
|
|
370
|
+
)}
|
|
371
|
+
data-state={inDrawer ? (ctx.drawerOpen ? 'open' : 'closed') : 'inline'}
|
|
372
|
+
aria-hidden={drawerClosed || undefined}
|
|
373
|
+
{...rest}
|
|
374
|
+
>
|
|
375
|
+
{children}
|
|
376
|
+
</ul>
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// EvoTopNav.Item — polymorphic
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
const EvoTopNavItem = forwardRef<HTMLElement, EvoTopNavItemProps>(
|
|
386
|
+
function EvoTopNavItem(
|
|
387
|
+
{ children, active, icon, href, target, rel, onClick, asChild, className },
|
|
388
|
+
ref,
|
|
389
|
+
) {
|
|
390
|
+
const ctx = React.useContext(TopNavContext);
|
|
391
|
+
const inDrawerCtx = !!ctx && ctx.isCollapsed && ctx.drawerOpen;
|
|
392
|
+
|
|
393
|
+
// Auto-close drawer on activation (SPA route nav heuristic).
|
|
394
|
+
const handleActivation = (e: React.MouseEvent) => {
|
|
395
|
+
onClick?.(e);
|
|
396
|
+
if (!e.defaultPrevented && inDrawerCtx) ctx.setDrawerOpen(false);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const shared = {
|
|
400
|
+
className: cn(styles.topNavItem, active && styles.topNavItemActive, className),
|
|
401
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
402
|
+
'data-active': active || undefined,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const content = (
|
|
406
|
+
<>
|
|
407
|
+
{icon && (
|
|
408
|
+
<span className={styles.topNavIcon} aria-hidden="true">
|
|
409
|
+
{icon}
|
|
410
|
+
</span>
|
|
411
|
+
)}
|
|
412
|
+
<span className={styles.topNavItemLabel}>{children}</span>
|
|
413
|
+
</>
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
let element: React.ReactElement;
|
|
417
|
+
|
|
418
|
+
if (asChild) {
|
|
419
|
+
element = (
|
|
420
|
+
<Slot
|
|
421
|
+
{...shared}
|
|
422
|
+
onClick={handleActivation}
|
|
423
|
+
ref={ref as React.Ref<HTMLElement>}
|
|
424
|
+
>
|
|
425
|
+
{children}
|
|
426
|
+
</Slot>
|
|
427
|
+
);
|
|
428
|
+
} else if (href) {
|
|
429
|
+
element = (
|
|
430
|
+
<a
|
|
431
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
432
|
+
href={href}
|
|
433
|
+
target={target}
|
|
434
|
+
rel={
|
|
435
|
+
rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)
|
|
436
|
+
}
|
|
437
|
+
onClick={handleActivation}
|
|
438
|
+
{...shared}
|
|
439
|
+
>
|
|
440
|
+
{content}
|
|
441
|
+
</a>
|
|
442
|
+
);
|
|
443
|
+
} else {
|
|
444
|
+
element = (
|
|
445
|
+
<button
|
|
446
|
+
ref={ref as React.Ref<HTMLButtonElement>}
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={handleActivation}
|
|
449
|
+
{...shared}
|
|
450
|
+
>
|
|
451
|
+
{content}
|
|
452
|
+
</button>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return <li className={styles.topNavItemRow}>{element}</li>;
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// EvoTopNav.Actions
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
const EvoTopNavActions = forwardRef<HTMLDivElement, EvoTopNavActionsProps>(
|
|
465
|
+
function EvoTopNavActions({ children, className, ...rest }, ref) {
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
ref={ref}
|
|
469
|
+
className={cn(styles.topNavActions, className)}
|
|
470
|
+
{...rest}
|
|
471
|
+
>
|
|
472
|
+
{children}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// ============================================================================
|
|
479
|
+
// EvoTopNav.Toggle — hamburger
|
|
480
|
+
// ============================================================================
|
|
481
|
+
|
|
482
|
+
const EvoTopNavToggle = forwardRef<HTMLButtonElement, EvoTopNavToggleProps>(
|
|
483
|
+
function EvoTopNavToggle(
|
|
484
|
+
{ icon, className, onClick, 'aria-label': ariaLabel, ...rest },
|
|
485
|
+
forwardedRef,
|
|
486
|
+
) {
|
|
487
|
+
const ctx = useTopNavContext('Toggle');
|
|
488
|
+
const localRef = useRef<HTMLButtonElement | null>(null);
|
|
489
|
+
|
|
490
|
+
useEffect(() => ctx.registerToggle(), [ctx]);
|
|
491
|
+
|
|
492
|
+
const setRef = (node: HTMLButtonElement | null) => {
|
|
493
|
+
localRef.current = node;
|
|
494
|
+
ctx.toggleRef.current = node;
|
|
495
|
+
if (typeof forwardedRef === 'function') forwardedRef(node);
|
|
496
|
+
else if (forwardedRef)
|
|
497
|
+
(forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
501
|
+
onClick?.(e);
|
|
502
|
+
if (!e.defaultPrevented) ctx.setDrawerOpen(!ctx.drawerOpen);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<button
|
|
507
|
+
ref={setRef}
|
|
508
|
+
id={ctx.toggleId}
|
|
509
|
+
type="button"
|
|
510
|
+
className={cn(styles.topNavToggle, className)}
|
|
511
|
+
aria-expanded={ctx.drawerOpen}
|
|
512
|
+
aria-controls={ctx.menuId}
|
|
513
|
+
aria-label={
|
|
514
|
+
ariaLabel ?? (ctx.drawerOpen ? 'Close menu' : 'Open menu')
|
|
515
|
+
}
|
|
516
|
+
onClick={handleClick}
|
|
517
|
+
{...rest}
|
|
518
|
+
>
|
|
519
|
+
{icon ?? <HamburgerIcon open={ctx.drawerOpen} />}
|
|
520
|
+
</button>
|
|
521
|
+
);
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// ============================================================================
|
|
526
|
+
// EvoTopNav.Dropdown
|
|
527
|
+
// ============================================================================
|
|
528
|
+
|
|
529
|
+
const EvoTopNavDropdown: React.FC<EvoTopNavDropdownProps> = ({
|
|
530
|
+
label,
|
|
531
|
+
icon,
|
|
532
|
+
active,
|
|
533
|
+
hoverable = true,
|
|
534
|
+
open,
|
|
535
|
+
defaultOpen = false,
|
|
536
|
+
onOpenChange,
|
|
537
|
+
children,
|
|
538
|
+
className,
|
|
539
|
+
}) => {
|
|
540
|
+
const ctx = React.useContext(TopNavContext);
|
|
541
|
+
const inDrawer = !!ctx && ctx.isCollapsed && ctx.toggleCount > 0;
|
|
542
|
+
|
|
543
|
+
const [isOpen, setOpen] = useControllableState({
|
|
544
|
+
value: open,
|
|
545
|
+
defaultValue: defaultOpen,
|
|
546
|
+
onChange: onOpenChange,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const hoverCapable = useHoverCapable();
|
|
550
|
+
const allowHover = hoverable && hoverCapable && !inDrawer;
|
|
551
|
+
|
|
552
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
553
|
+
const contentRef = useRef<HTMLUListElement | null>(null);
|
|
554
|
+
const rootRef = useRef<HTMLLIElement | null>(null);
|
|
555
|
+
const contentId = useId();
|
|
556
|
+
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
557
|
+
|
|
558
|
+
const closeAndRestore = useCallback(() => {
|
|
559
|
+
setOpen(false);
|
|
560
|
+
triggerRef.current?.focus();
|
|
561
|
+
}, [setOpen]);
|
|
562
|
+
|
|
563
|
+
// Click outside
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
if (!isOpen || inDrawer) return;
|
|
566
|
+
const onDocDown = (e: MouseEvent) => {
|
|
567
|
+
const root = rootRef.current;
|
|
568
|
+
if (root && e.target instanceof Node && !root.contains(e.target)) {
|
|
569
|
+
setOpen(false);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
document.addEventListener('mousedown', onDocDown);
|
|
573
|
+
return () => document.removeEventListener('mousedown', onDocDown);
|
|
574
|
+
}, [isOpen, inDrawer, setOpen]);
|
|
575
|
+
|
|
576
|
+
// Escape to close (only desktop dropdown — drawer handles its own Esc)
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
if (!isOpen || inDrawer) return;
|
|
579
|
+
const onKey = (e: KeyboardEvent) => {
|
|
580
|
+
if (e.key === 'Escape') {
|
|
581
|
+
e.stopPropagation();
|
|
582
|
+
closeAndRestore();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
document.addEventListener('keydown', onKey);
|
|
586
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
587
|
+
}, [isOpen, inDrawer, closeAndRestore]);
|
|
588
|
+
|
|
589
|
+
const focusItem = useCallback(
|
|
590
|
+
(delta: 1 | -1 | 'first' | 'last') => {
|
|
591
|
+
const items = getFocusable(contentRef.current);
|
|
592
|
+
if (items.length === 0) return;
|
|
593
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
594
|
+
const idx = activeEl ? items.indexOf(activeEl) : -1;
|
|
595
|
+
let next = 0;
|
|
596
|
+
if (delta === 'first') next = 0;
|
|
597
|
+
else if (delta === 'last') next = items.length - 1;
|
|
598
|
+
else if (delta === 1) next = idx < 0 ? 0 : (idx + 1) % items.length;
|
|
599
|
+
else next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
|
|
600
|
+
items[next]?.focus();
|
|
601
|
+
},
|
|
602
|
+
[],
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const handleTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
606
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
|
607
|
+
e.preventDefault();
|
|
608
|
+
setOpen(true);
|
|
609
|
+
requestAnimationFrame(() => focusItem('first'));
|
|
610
|
+
} else if (e.key === 'ArrowUp') {
|
|
611
|
+
e.preventDefault();
|
|
612
|
+
setOpen(true);
|
|
613
|
+
requestAnimationFrame(() => focusItem('last'));
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const handleContentKey = (e: React.KeyboardEvent<HTMLUListElement>) => {
|
|
618
|
+
if (e.key === 'ArrowDown') {
|
|
619
|
+
e.preventDefault();
|
|
620
|
+
focusItem(1);
|
|
621
|
+
} else if (e.key === 'ArrowUp') {
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
focusItem(-1);
|
|
624
|
+
} else if (e.key === 'Home') {
|
|
625
|
+
e.preventDefault();
|
|
626
|
+
focusItem('first');
|
|
627
|
+
} else if (e.key === 'End') {
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
focusItem('last');
|
|
630
|
+
} else if (e.key === 'Tab') {
|
|
631
|
+
// Allow Tab to leave the dropdown; close it.
|
|
632
|
+
setOpen(false);
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const onMouseEnter = () => {
|
|
637
|
+
if (!allowHover) return;
|
|
638
|
+
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
639
|
+
setOpen(true);
|
|
640
|
+
};
|
|
641
|
+
const onMouseLeave = () => {
|
|
642
|
+
if (!allowHover) return;
|
|
643
|
+
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
644
|
+
hoverTimer.current = setTimeout(() => setOpen(false), 120);
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const dropdownCtx = useMemo<DropdownContextValue>(
|
|
648
|
+
() => ({
|
|
649
|
+
open: isOpen,
|
|
650
|
+
setOpen,
|
|
651
|
+
triggerRef,
|
|
652
|
+
contentRef,
|
|
653
|
+
contentId,
|
|
654
|
+
focusItem,
|
|
655
|
+
inDrawer,
|
|
656
|
+
}),
|
|
657
|
+
[isOpen, setOpen, contentId, focusItem, inDrawer],
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<DropdownContext.Provider value={dropdownCtx}>
|
|
662
|
+
<li
|
|
663
|
+
ref={rootRef}
|
|
664
|
+
className={cn(
|
|
665
|
+
styles.topNavDropdown,
|
|
666
|
+
inDrawer && styles.topNavDropdownInDrawer,
|
|
667
|
+
isOpen && styles.topNavDropdownOpen,
|
|
668
|
+
className,
|
|
669
|
+
)}
|
|
670
|
+
onMouseEnter={onMouseEnter}
|
|
671
|
+
onMouseLeave={onMouseLeave}
|
|
672
|
+
>
|
|
673
|
+
<button
|
|
674
|
+
ref={triggerRef}
|
|
675
|
+
type="button"
|
|
676
|
+
className={cn(
|
|
677
|
+
styles.topNavItem,
|
|
678
|
+
styles.topNavDropdownTrigger,
|
|
679
|
+
active && styles.topNavItemActive,
|
|
680
|
+
)}
|
|
681
|
+
aria-haspopup="menu"
|
|
682
|
+
aria-expanded={isOpen}
|
|
683
|
+
aria-controls={contentId}
|
|
684
|
+
aria-current={active ? 'page' : undefined}
|
|
685
|
+
onClick={() => setOpen(!isOpen)}
|
|
686
|
+
onKeyDown={handleTriggerKey}
|
|
687
|
+
>
|
|
688
|
+
{icon && (
|
|
689
|
+
<span className={styles.topNavIcon} aria-hidden="true">
|
|
690
|
+
{icon}
|
|
691
|
+
</span>
|
|
692
|
+
)}
|
|
693
|
+
<span className={styles.topNavItemLabel}>{label}</span>
|
|
694
|
+
<ChevronIcon open={isOpen} />
|
|
695
|
+
</button>
|
|
696
|
+
<ul
|
|
697
|
+
ref={contentRef}
|
|
698
|
+
id={contentId}
|
|
699
|
+
role="menu"
|
|
700
|
+
className={cn(
|
|
701
|
+
styles.topNavDropdownContent,
|
|
702
|
+
isOpen && styles.topNavDropdownContentOpen,
|
|
703
|
+
)}
|
|
704
|
+
// Note: visibility is driven by the .topNavDropdownContentOpen class
|
|
705
|
+
// (display: none → flex). Avoid the `hidden` attribute because
|
|
706
|
+
// `[hidden] { display: none }` is overridden by class-level `display`
|
|
707
|
+
// rules — the two-source-of-truth setup leaked an empty panel onto
|
|
708
|
+
// the page in v1.
|
|
709
|
+
aria-hidden={!isOpen || undefined}
|
|
710
|
+
onKeyDown={handleContentKey}
|
|
711
|
+
>
|
|
712
|
+
{children}
|
|
713
|
+
</ul>
|
|
714
|
+
</li>
|
|
715
|
+
</DropdownContext.Provider>
|
|
716
|
+
);
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// ============================================================================
|
|
720
|
+
// EvoTopNav.DropdownItem
|
|
721
|
+
// ============================================================================
|
|
722
|
+
|
|
723
|
+
const EvoTopNavDropdownItem = forwardRef<HTMLElement, EvoTopNavDropdownItemProps>(
|
|
724
|
+
function EvoTopNavDropdownItem(
|
|
725
|
+
{ children, icon, active, href, target, rel, onClick, asChild, className },
|
|
726
|
+
ref,
|
|
727
|
+
) {
|
|
728
|
+
const dropdown = React.useContext(DropdownContext);
|
|
729
|
+
const top = React.useContext(TopNavContext);
|
|
730
|
+
|
|
731
|
+
const handleActivation = (e: React.MouseEvent) => {
|
|
732
|
+
onClick?.(e);
|
|
733
|
+
if (!e.defaultPrevented) {
|
|
734
|
+
dropdown?.setOpen(false);
|
|
735
|
+
// Also close the drawer if we're inside it.
|
|
736
|
+
if (top && top.isCollapsed && top.drawerOpen) top.setDrawerOpen(false);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const shared = {
|
|
741
|
+
className: cn(
|
|
742
|
+
styles.topNavDropdownItem,
|
|
743
|
+
active && styles.topNavItemActive,
|
|
744
|
+
className,
|
|
745
|
+
),
|
|
746
|
+
role: 'menuitem' as const,
|
|
747
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const content = (
|
|
751
|
+
<>
|
|
752
|
+
{icon && (
|
|
753
|
+
<span className={styles.topNavIcon} aria-hidden="true">
|
|
754
|
+
{icon}
|
|
755
|
+
</span>
|
|
756
|
+
)}
|
|
757
|
+
<span className={styles.topNavItemLabel}>{children}</span>
|
|
758
|
+
</>
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
let element: React.ReactElement;
|
|
762
|
+
|
|
763
|
+
if (asChild) {
|
|
764
|
+
element = (
|
|
765
|
+
<Slot
|
|
766
|
+
{...shared}
|
|
767
|
+
onClick={handleActivation}
|
|
768
|
+
ref={ref as React.Ref<HTMLElement>}
|
|
769
|
+
>
|
|
770
|
+
{children}
|
|
771
|
+
</Slot>
|
|
772
|
+
);
|
|
773
|
+
} else if (href) {
|
|
774
|
+
element = (
|
|
775
|
+
<a
|
|
776
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
777
|
+
href={href}
|
|
778
|
+
target={target}
|
|
779
|
+
rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
|
|
780
|
+
onClick={handleActivation}
|
|
781
|
+
{...shared}
|
|
782
|
+
>
|
|
783
|
+
{content}
|
|
784
|
+
</a>
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
element = (
|
|
788
|
+
<button
|
|
789
|
+
ref={ref as React.Ref<HTMLButtonElement>}
|
|
790
|
+
type="button"
|
|
791
|
+
onClick={handleActivation}
|
|
792
|
+
{...shared}
|
|
793
|
+
>
|
|
794
|
+
{content}
|
|
795
|
+
</button>
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return <li>{element}</li>;
|
|
800
|
+
},
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
// ============================================================================
|
|
804
|
+
// EvoTopNav — root
|
|
805
|
+
// ============================================================================
|
|
806
|
+
|
|
807
|
+
export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
|
|
808
|
+
function EvoTopNav(
|
|
809
|
+
{
|
|
810
|
+
children,
|
|
811
|
+
open,
|
|
812
|
+
defaultOpen = false,
|
|
813
|
+
onOpenChange,
|
|
814
|
+
collapseBelow = 768,
|
|
815
|
+
className,
|
|
816
|
+
...rest
|
|
817
|
+
},
|
|
818
|
+
ref,
|
|
819
|
+
) {
|
|
820
|
+
const [drawerOpen, setDrawerOpen] = useControllableState({
|
|
821
|
+
value: open,
|
|
822
|
+
defaultValue: defaultOpen,
|
|
823
|
+
onChange: onOpenChange,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const isCollapsed = useIsCollapsed(collapseBelow);
|
|
827
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
828
|
+
|
|
829
|
+
const [toggleCount, setToggleCount] = useState(0);
|
|
830
|
+
const registerToggle = useCallback(() => {
|
|
831
|
+
setToggleCount((c) => c + 1);
|
|
832
|
+
return () => setToggleCount((c) => Math.max(0, c - 1));
|
|
833
|
+
}, []);
|
|
834
|
+
|
|
835
|
+
const menuId = useId();
|
|
836
|
+
const toggleId = useId();
|
|
837
|
+
const menuRef = useRef<HTMLUListElement | null>(null);
|
|
838
|
+
const toggleRef = useRef<HTMLButtonElement | null>(null);
|
|
839
|
+
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
840
|
+
|
|
841
|
+
const drawerActive = isCollapsed && drawerOpen && toggleCount > 0;
|
|
842
|
+
|
|
843
|
+
// Close drawer when leaving the collapsed breakpoint.
|
|
844
|
+
useEffect(() => {
|
|
845
|
+
if (!isCollapsed && drawerOpen) setDrawerOpen(false);
|
|
846
|
+
}, [isCollapsed, drawerOpen, setDrawerOpen]);
|
|
847
|
+
|
|
848
|
+
// Body scroll lock while drawer is open.
|
|
849
|
+
useEffect(() => {
|
|
850
|
+
if (!drawerActive) return;
|
|
851
|
+
const prev = document.body.style.overflow;
|
|
852
|
+
document.body.style.overflow = 'hidden';
|
|
853
|
+
return () => {
|
|
854
|
+
document.body.style.overflow = prev;
|
|
855
|
+
};
|
|
856
|
+
}, [drawerActive]);
|
|
857
|
+
|
|
858
|
+
// Esc to close + focus trap inside Menu while drawer is open.
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
if (!drawerActive) return;
|
|
861
|
+
restoreFocusRef.current =
|
|
862
|
+
(document.activeElement as HTMLElement | null) ?? null;
|
|
863
|
+
|
|
864
|
+
const focusFirst = () => {
|
|
865
|
+
const items = getFocusable(menuRef.current);
|
|
866
|
+
items[0]?.focus();
|
|
867
|
+
};
|
|
868
|
+
const id = requestAnimationFrame(focusFirst);
|
|
869
|
+
|
|
870
|
+
const onKey = (e: KeyboardEvent) => {
|
|
871
|
+
if (e.key === 'Escape') {
|
|
872
|
+
e.preventDefault();
|
|
873
|
+
setDrawerOpen(false);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (e.key !== 'Tab' || !menuRef.current) return;
|
|
877
|
+
const items = getFocusable(menuRef.current);
|
|
878
|
+
if (items.length === 0) return;
|
|
879
|
+
const first = items[0];
|
|
880
|
+
const last = items[items.length - 1];
|
|
881
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
882
|
+
if (e.shiftKey && activeEl === first) {
|
|
883
|
+
e.preventDefault();
|
|
884
|
+
last.focus();
|
|
885
|
+
} else if (!e.shiftKey && activeEl === last) {
|
|
886
|
+
e.preventDefault();
|
|
887
|
+
first.focus();
|
|
888
|
+
} else if (activeEl && !menuRef.current.contains(activeEl)) {
|
|
889
|
+
e.preventDefault();
|
|
890
|
+
first.focus();
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
document.addEventListener('keydown', onKey);
|
|
894
|
+
return () => {
|
|
895
|
+
cancelAnimationFrame(id);
|
|
896
|
+
document.removeEventListener('keydown', onKey);
|
|
897
|
+
};
|
|
898
|
+
}, [drawerActive, setDrawerOpen]);
|
|
899
|
+
|
|
900
|
+
// Restore focus when drawer closes.
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
if (drawerActive) return;
|
|
903
|
+
const target = restoreFocusRef.current;
|
|
904
|
+
if (target && typeof target.focus === 'function') {
|
|
905
|
+
// Only restore if focus is currently inside the menu (or nowhere).
|
|
906
|
+
const active = document.activeElement;
|
|
907
|
+
if (
|
|
908
|
+
!active ||
|
|
909
|
+
active === document.body ||
|
|
910
|
+
(menuRef.current && menuRef.current.contains(active))
|
|
911
|
+
) {
|
|
912
|
+
target.focus();
|
|
913
|
+
}
|
|
914
|
+
restoreFocusRef.current = null;
|
|
915
|
+
}
|
|
916
|
+
}, [drawerActive]);
|
|
917
|
+
|
|
918
|
+
const ctxValue = useMemo<TopNavContextValue>(
|
|
919
|
+
() => ({
|
|
920
|
+
drawerOpen,
|
|
921
|
+
setDrawerOpen,
|
|
922
|
+
isCollapsed,
|
|
923
|
+
menuId,
|
|
924
|
+
toggleId,
|
|
925
|
+
registerToggle,
|
|
926
|
+
toggleCount,
|
|
927
|
+
menuRef,
|
|
928
|
+
toggleRef,
|
|
929
|
+
}),
|
|
930
|
+
[
|
|
931
|
+
drawerOpen,
|
|
932
|
+
setDrawerOpen,
|
|
933
|
+
isCollapsed,
|
|
934
|
+
menuId,
|
|
935
|
+
toggleId,
|
|
936
|
+
registerToggle,
|
|
937
|
+
toggleCount,
|
|
938
|
+
],
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return (
|
|
942
|
+
<TopNavContext.Provider value={ctxValue}>
|
|
943
|
+
<nav
|
|
944
|
+
ref={ref}
|
|
945
|
+
className={cn(
|
|
946
|
+
styles.topNav,
|
|
947
|
+
drawerActive && styles.topNavDrawerOpen,
|
|
948
|
+
reducedMotion && styles.topNavReducedMotion,
|
|
949
|
+
className,
|
|
950
|
+
)}
|
|
951
|
+
data-collapsed={isCollapsed || undefined}
|
|
952
|
+
data-drawer-open={drawerActive || undefined}
|
|
953
|
+
{...rest}
|
|
954
|
+
>
|
|
955
|
+
<div className={styles.topNavInner}>{children}</div>
|
|
956
|
+
{drawerActive && (
|
|
957
|
+
<div
|
|
958
|
+
className={styles.topNavBackdrop}
|
|
959
|
+
onClick={() => setDrawerOpen(false)}
|
|
960
|
+
aria-hidden="true"
|
|
961
|
+
/>
|
|
962
|
+
)}
|
|
963
|
+
</nav>
|
|
964
|
+
</TopNavContext.Provider>
|
|
965
|
+
);
|
|
966
|
+
},
|
|
967
|
+
) as React.ForwardRefExoticComponent<
|
|
968
|
+
EvoTopNavProps & React.RefAttributes<HTMLElement>
|
|
969
|
+
> & {
|
|
970
|
+
Brand: typeof EvoTopNavBrand;
|
|
971
|
+
Menu: typeof EvoTopNavMenu;
|
|
972
|
+
Item: typeof EvoTopNavItem;
|
|
973
|
+
Actions: typeof EvoTopNavActions;
|
|
974
|
+
Toggle: typeof EvoTopNavToggle;
|
|
975
|
+
Dropdown: typeof EvoTopNavDropdown;
|
|
976
|
+
DropdownItem: typeof EvoTopNavDropdownItem;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
EvoTopNavBrand.displayName = 'EvoTopNav.Brand';
|
|
980
|
+
EvoTopNavMenu.displayName = 'EvoTopNav.Menu';
|
|
981
|
+
EvoTopNavItem.displayName = 'EvoTopNav.Item';
|
|
982
|
+
EvoTopNavActions.displayName = 'EvoTopNav.Actions';
|
|
983
|
+
EvoTopNavToggle.displayName = 'EvoTopNav.Toggle';
|
|
984
|
+
EvoTopNavDropdown.displayName = 'EvoTopNav.Dropdown';
|
|
985
|
+
EvoTopNavDropdownItem.displayName = 'EvoTopNav.DropdownItem';
|
|
986
|
+
(EvoTopNav as { displayName?: string }).displayName = 'EvoTopNav';
|
|
987
|
+
|
|
988
|
+
EvoTopNav.Brand = EvoTopNavBrand;
|
|
989
|
+
EvoTopNav.Menu = EvoTopNavMenu;
|
|
990
|
+
EvoTopNav.Item = EvoTopNavItem;
|
|
991
|
+
EvoTopNav.Actions = EvoTopNavActions;
|
|
992
|
+
EvoTopNav.Toggle = EvoTopNavToggle;
|
|
993
|
+
EvoTopNav.Dropdown = EvoTopNavDropdown;
|
|
994
|
+
EvoTopNav.DropdownItem = EvoTopNavDropdownItem;
|