@justin_evo/evo-ui 1.0.2 → 1.2.0
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 -0
- package/README.md +70 -70
- package/dist/Nav/Nav.d.ts +15 -0
- package/dist/TopNav/TopNav.d.ts +19 -0
- package/dist/evo-ui.css +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +3300 -3157
- package/package.json +1 -1
- package/src/Nav/Nav.tsx +96 -14
- package/src/RichTextArea/RichTextArea.tsx +20 -3
- package/src/TopNav/TopNav.tsx +169 -0
- package/src/css/checkbox.module.scss +8 -5
- package/src/css/nav.module.scss +170 -15
- package/src/css/topnav.module.scss +172 -0
package/package.json
CHANGED
package/src/Nav/Nav.tsx
CHANGED
|
@@ -52,6 +52,9 @@ export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children
|
|
|
52
52
|
onDrawerOpenChange?: (open: boolean) => void;
|
|
53
53
|
/** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
|
|
54
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;
|
|
55
58
|
/** Accessible label for the <nav> landmark. @default 'Main navigation' */
|
|
56
59
|
'aria-label'?: string;
|
|
57
60
|
}
|
|
@@ -60,6 +63,16 @@ export interface EvoNavGroupProps {
|
|
|
60
63
|
label: string;
|
|
61
64
|
children: ReactNode;
|
|
62
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;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
interface EvoNavRowProps {
|
|
@@ -70,6 +83,8 @@ interface EvoNavRowProps {
|
|
|
70
83
|
/** Render as <a href> instead of <button>. */
|
|
71
84
|
href?: string;
|
|
72
85
|
onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
|
|
86
|
+
/** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
|
|
87
|
+
tooltip?: string;
|
|
73
88
|
/** Controlled expand state (only when row has SubItem children). */
|
|
74
89
|
open?: boolean;
|
|
75
90
|
/** Uncontrolled initial expand state. */
|
|
@@ -106,6 +121,8 @@ interface NavRootContextValue {
|
|
|
106
121
|
closeDrawer: () => void;
|
|
107
122
|
/** Root id used by the hamburger button's aria-controls. */
|
|
108
123
|
rootId: string;
|
|
124
|
+
/** Icon-only rail mode — labels hide, rows surface a native tooltip. */
|
|
125
|
+
collapsed: boolean;
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
const NavRootContext = createContext<NavRootContextValue | null>(null);
|
|
@@ -160,7 +177,7 @@ function focusableRows(root: HTMLElement | null): HTMLElement[] {
|
|
|
160
177
|
if (!root) return [];
|
|
161
178
|
return Array.from(
|
|
162
179
|
root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
|
|
163
|
-
).filter((el) => el.offsetParent !== null);
|
|
180
|
+
).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
|
|
@@ -242,6 +259,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
|
|
|
242
259
|
active = false,
|
|
243
260
|
href,
|
|
244
261
|
onClick,
|
|
262
|
+
tooltip,
|
|
245
263
|
open: openProp,
|
|
246
264
|
defaultOpen = false,
|
|
247
265
|
onOpenChange,
|
|
@@ -252,6 +270,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
|
|
|
252
270
|
liRef,
|
|
253
271
|
) {
|
|
254
272
|
const rootCtx = useContext(NavRootContext);
|
|
273
|
+
const collapsed = rootCtx?.collapsed ?? false;
|
|
255
274
|
const { depth } = useContext(NavDepthContext);
|
|
256
275
|
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
|
257
276
|
const rowId = useId();
|
|
@@ -364,6 +383,7 @@ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
|
|
|
364
383
|
id: rowId,
|
|
365
384
|
className: rowClasses,
|
|
366
385
|
style: rowStyle,
|
|
386
|
+
title: collapsed && tooltip ? tooltip : undefined,
|
|
367
387
|
'aria-current': active ? ('page' as const) : undefined,
|
|
368
388
|
'aria-expanded': expandable ? open : undefined,
|
|
369
389
|
'aria-controls': expandable ? subListId : undefined,
|
|
@@ -450,21 +470,81 @@ export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(funct
|
|
|
450
470
|
EvoNavSubItem.displayName = 'EvoNavSubItem';
|
|
451
471
|
|
|
452
472
|
export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
|
|
453
|
-
{ label, children, className },
|
|
473
|
+
{ label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
|
|
454
474
|
ref,
|
|
455
475
|
) {
|
|
456
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
|
+
|
|
457
515
|
return (
|
|
458
|
-
<li
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
)}
|
|
468
548
|
</li>
|
|
469
549
|
);
|
|
470
550
|
});
|
|
@@ -517,6 +597,7 @@ const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
|
|
|
517
597
|
defaultDrawerOpen = false,
|
|
518
598
|
onDrawerOpenChange,
|
|
519
599
|
hideTrigger = false,
|
|
600
|
+
collapsed = false,
|
|
520
601
|
className,
|
|
521
602
|
'aria-label': ariaLabel = 'Main navigation',
|
|
522
603
|
...rest
|
|
@@ -560,14 +641,15 @@ const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
|
|
|
560
641
|
}, [isMobile, drawerOpen, closeDrawer]);
|
|
561
642
|
|
|
562
643
|
const ctxValue = useMemo<NavRootContextValue>(
|
|
563
|
-
() => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId }),
|
|
564
|
-
[isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId],
|
|
644
|
+
() => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
|
|
645
|
+
[isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
|
|
565
646
|
);
|
|
566
647
|
|
|
567
648
|
const navClasses = [
|
|
568
649
|
styles.navContainer,
|
|
569
650
|
isMobile ? styles.navMobile : styles.navDesktop,
|
|
570
651
|
isMobile && drawerOpen ? styles.navDrawerOpen : '',
|
|
652
|
+
collapsed ? styles.navCollapsed : '',
|
|
571
653
|
className,
|
|
572
654
|
]
|
|
573
655
|
.filter(Boolean)
|
|
@@ -502,9 +502,26 @@ export const EvoRichTextArea = forwardRef<EvoRichTextHandle, EvoRichTextAreaProp
|
|
|
502
502
|
|
|
503
503
|
// ---- Insert image (used by paste, drop, button) ----
|
|
504
504
|
const insertImageAtCaret = useCallback((src: string, alt = '') => {
|
|
505
|
-
editorRef.current
|
|
506
|
-
|
|
507
|
-
|
|
505
|
+
const el = editorRef.current;
|
|
506
|
+
if (!el) return;
|
|
507
|
+
el.focus();
|
|
508
|
+
const safeSrc = src.replace(/"/g, '"');
|
|
509
|
+
const safeAlt = alt.replace(/"/g, '"');
|
|
510
|
+
// Images render display:block; inserting a bare <img> leaves the caret
|
|
511
|
+
// beside it, which paints at the image's top-right edge. Drop a trailing
|
|
512
|
+
// empty paragraph and move the caret into it, so the user lands on a clean
|
|
513
|
+
// new line *below* the image. (Marker idiom matches unwrapBlocks above.)
|
|
514
|
+
execCommand('insertHTML', `<img src="${safeSrc}" alt="${safeAlt}" /><p data-evo-caret><br></p>`);
|
|
515
|
+
const landing = el.querySelector<HTMLParagraphElement>('p[data-evo-caret]');
|
|
516
|
+
if (landing) {
|
|
517
|
+
landing.removeAttribute('data-evo-caret');
|
|
518
|
+
const sel = window.getSelection();
|
|
519
|
+
const r = document.createRange();
|
|
520
|
+
r.setStart(landing, 0);
|
|
521
|
+
r.collapse(true);
|
|
522
|
+
sel?.removeAllRanges();
|
|
523
|
+
sel?.addRange(r);
|
|
524
|
+
}
|
|
508
525
|
emitChange();
|
|
509
526
|
}, [emitChange]);
|
|
510
527
|
|
package/src/TopNav/TopNav.tsx
CHANGED
|
@@ -32,6 +32,14 @@ export interface EvoTopNavProps
|
|
|
32
32
|
onOpenChange?: (open: boolean) => void;
|
|
33
33
|
/** Width in px below which Menu collapses into the drawer. @default 768 */
|
|
34
34
|
collapseBelow?: number;
|
|
35
|
+
/** Staggered mount animation for the bar's contents. @default 'none' */
|
|
36
|
+
entrance?: 'none' | 'rise' | 'fade';
|
|
37
|
+
/** Pin the bar with position: sticky; top: 0. @default false */
|
|
38
|
+
sticky?: boolean;
|
|
39
|
+
/** On-scroll treatment of a sticky bar. @default 'none' */
|
|
40
|
+
scrollBehavior?: 'none' | 'elevate' | 'shrink' | 'hide';
|
|
41
|
+
/** Render a thin scroll-progress accent line along the bottom edge. @default false */
|
|
42
|
+
showProgress?: boolean;
|
|
35
43
|
className?: string;
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -74,6 +82,17 @@ export interface EvoTopNavToggleProps
|
|
|
74
82
|
className?: string;
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
export interface EvoTopNavSearchProps
|
|
86
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
87
|
+
/** Placeholder text shown inside the trigger. @default 'Search…' */
|
|
88
|
+
placeholder?: string;
|
|
89
|
+
/** Opt-in global hotkey, e.g. 'mod+k' (mod = ⌘ on macOS, Ctrl elsewhere). Default: none. */
|
|
90
|
+
shortcut?: string;
|
|
91
|
+
/** Override the kbd hint. @default platform-aware ⌘K / Ctrl K */
|
|
92
|
+
shortcutHint?: React.ReactNode;
|
|
93
|
+
className?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
export interface EvoTopNavDropdownProps {
|
|
78
97
|
label: React.ReactNode;
|
|
79
98
|
icon?: React.ReactNode;
|
|
@@ -159,6 +178,53 @@ const useHoverCapable = () =>
|
|
|
159
178
|
const usePrefersReducedMotion = () =>
|
|
160
179
|
useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
161
180
|
|
|
181
|
+
function useScrollState(
|
|
182
|
+
enabled: boolean,
|
|
183
|
+
behavior: 'none' | 'elevate' | 'shrink' | 'hide',
|
|
184
|
+
wantProgress: boolean,
|
|
185
|
+
) {
|
|
186
|
+
const [scrolled, setScrolled] = useState(false);
|
|
187
|
+
const [hidden, setHidden] = useState(false);
|
|
188
|
+
const [progress, setProgress] = useState(0);
|
|
189
|
+
const lastY = useRef(0);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (typeof window === 'undefined' || !enabled) {
|
|
193
|
+
setScrolled(false);
|
|
194
|
+
setHidden(false);
|
|
195
|
+
setProgress(0);
|
|
196
|
+
lastY.current = 0;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
let raf = 0;
|
|
200
|
+
const read = () => {
|
|
201
|
+
raf = 0;
|
|
202
|
+
const doc = document.documentElement;
|
|
203
|
+
const y = window.scrollY || doc.scrollTop || 0;
|
|
204
|
+
setScrolled(y > 8);
|
|
205
|
+
setHidden(behavior === 'hide' ? y > lastY.current && y > 64 : false);
|
|
206
|
+
if (wantProgress) {
|
|
207
|
+
const max = doc.scrollHeight - doc.clientHeight || 1;
|
|
208
|
+
setProgress(Math.min(1, Math.max(0, y / max)));
|
|
209
|
+
}
|
|
210
|
+
lastY.current = y;
|
|
211
|
+
};
|
|
212
|
+
const onScroll = () => {
|
|
213
|
+
if (!raf) raf = requestAnimationFrame(read);
|
|
214
|
+
};
|
|
215
|
+
read();
|
|
216
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
217
|
+
window.addEventListener('resize', onScroll, { passive: true });
|
|
218
|
+
return () => {
|
|
219
|
+
if (raf) cancelAnimationFrame(raf);
|
|
220
|
+
window.removeEventListener('scroll', onScroll);
|
|
221
|
+
window.removeEventListener('resize', onScroll);
|
|
222
|
+
};
|
|
223
|
+
}, [enabled, behavior, wantProgress]);
|
|
224
|
+
|
|
225
|
+
return { scrolled, hidden, progress };
|
|
226
|
+
}
|
|
227
|
+
|
|
162
228
|
function getFocusable(root: HTMLElement | null) {
|
|
163
229
|
if (!root) return [] as HTMLElement[];
|
|
164
230
|
return Array.from(
|
|
@@ -318,6 +384,23 @@ const ChevronIcon = ({ open }: { open: boolean }) => (
|
|
|
318
384
|
</svg>
|
|
319
385
|
);
|
|
320
386
|
|
|
387
|
+
const SearchGlyph = () => (
|
|
388
|
+
<svg
|
|
389
|
+
width="16"
|
|
390
|
+
height="16"
|
|
391
|
+
viewBox="0 0 24 24"
|
|
392
|
+
fill="none"
|
|
393
|
+
stroke="currentColor"
|
|
394
|
+
strokeWidth="2"
|
|
395
|
+
strokeLinecap="round"
|
|
396
|
+
strokeLinejoin="round"
|
|
397
|
+
aria-hidden="true"
|
|
398
|
+
>
|
|
399
|
+
<circle cx="11" cy="11" r="7" />
|
|
400
|
+
<path d="m20 20-3.2-3.2" />
|
|
401
|
+
</svg>
|
|
402
|
+
);
|
|
403
|
+
|
|
321
404
|
// ============================================================================
|
|
322
405
|
// EvoTopNav.Brand
|
|
323
406
|
// ============================================================================
|
|
@@ -522,6 +605,69 @@ const EvoTopNavToggle = forwardRef<HTMLButtonElement, EvoTopNavToggleProps>(
|
|
|
522
605
|
},
|
|
523
606
|
);
|
|
524
607
|
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// EvoTopNav.Search — presentational ⌘K quick-search trigger
|
|
610
|
+
// ============================================================================
|
|
611
|
+
|
|
612
|
+
const EvoTopNavSearch = forwardRef<HTMLButtonElement, EvoTopNavSearchProps>(
|
|
613
|
+
function EvoTopNavSearch(
|
|
614
|
+
{ placeholder = 'Search…', shortcut, shortcutHint, className, onClick, ...rest },
|
|
615
|
+
forwardedRef,
|
|
616
|
+
) {
|
|
617
|
+
const localRef = useRef<HTMLButtonElement | null>(null);
|
|
618
|
+
const setRef = (node: HTMLButtonElement | null) => {
|
|
619
|
+
localRef.current = node;
|
|
620
|
+
if (typeof forwardedRef === 'function') forwardedRef(node);
|
|
621
|
+
else if (forwardedRef)
|
|
622
|
+
(forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// Platform-aware hint resolved after mount to avoid SSR hydration mismatch.
|
|
626
|
+
const [autoHint, setAutoHint] = useState<React.ReactNode>(null);
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
if (shortcutHint !== undefined) return;
|
|
629
|
+
const platform =
|
|
630
|
+
(typeof navigator !== 'undefined' &&
|
|
631
|
+
(navigator.platform || navigator.userAgent)) || '';
|
|
632
|
+
setAutoHint(/Mac|iPhone|iPad|iPod/.test(platform) ? '⌘K' : 'Ctrl K');
|
|
633
|
+
}, [shortcutHint]);
|
|
634
|
+
const hint = shortcutHint !== undefined ? shortcutHint : autoHint;
|
|
635
|
+
|
|
636
|
+
// Opt-in global hotkey → dispatch a real click so onClick fires naturally.
|
|
637
|
+
useEffect(() => {
|
|
638
|
+
if (!shortcut) return;
|
|
639
|
+
const parts = shortcut.toLowerCase().split('+').map((p) => p.trim());
|
|
640
|
+
const wantMod = parts.some((p) => ['mod', 'cmd', 'meta', 'ctrl', 'control'].includes(p));
|
|
641
|
+
const key = parts[parts.length - 1];
|
|
642
|
+
const handler = (e: KeyboardEvent) => {
|
|
643
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
644
|
+
if ((!wantMod || mod) && e.key.toLowerCase() === key) {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
localRef.current?.click();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
document.addEventListener('keydown', handler);
|
|
650
|
+
return () => document.removeEventListener('keydown', handler);
|
|
651
|
+
}, [shortcut]);
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<button
|
|
655
|
+
ref={setRef}
|
|
656
|
+
className={cn(styles.topNavSearch, className)}
|
|
657
|
+
onClick={onClick}
|
|
658
|
+
{...rest}
|
|
659
|
+
type="button"
|
|
660
|
+
>
|
|
661
|
+
<span className={styles.topNavSearchIcon} aria-hidden="true">
|
|
662
|
+
<SearchGlyph />
|
|
663
|
+
</span>
|
|
664
|
+
<span className={styles.topNavSearchText}>{placeholder}</span>
|
|
665
|
+
{hint != null && <kbd className={styles.topNavSearchKbd}>{hint}</kbd>}
|
|
666
|
+
</button>
|
|
667
|
+
);
|
|
668
|
+
},
|
|
669
|
+
);
|
|
670
|
+
|
|
525
671
|
// ============================================================================
|
|
526
672
|
// EvoTopNav.Dropdown
|
|
527
673
|
// ============================================================================
|
|
@@ -812,7 +958,12 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
|
|
|
812
958
|
defaultOpen = false,
|
|
813
959
|
onOpenChange,
|
|
814
960
|
collapseBelow = 768,
|
|
961
|
+
entrance = 'none',
|
|
962
|
+
sticky = false,
|
|
963
|
+
scrollBehavior = 'none',
|
|
964
|
+
showProgress = false,
|
|
815
965
|
className,
|
|
966
|
+
style,
|
|
816
967
|
...rest
|
|
817
968
|
},
|
|
818
969
|
ref,
|
|
@@ -825,6 +976,12 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
|
|
|
825
976
|
|
|
826
977
|
const isCollapsed = useIsCollapsed(collapseBelow);
|
|
827
978
|
const reducedMotion = usePrefersReducedMotion();
|
|
979
|
+
const scrollEnabled = scrollBehavior !== 'none' || showProgress;
|
|
980
|
+
const { scrolled, hidden, progress } = useScrollState(scrollEnabled, scrollBehavior, showProgress);
|
|
981
|
+
const animateEntrance = entrance !== 'none' && !reducedMotion;
|
|
982
|
+
const mergedStyle: React.CSSProperties = showProgress
|
|
983
|
+
? ({ ...style, ['--evo-topnav-progress' as string]: progress } as React.CSSProperties)
|
|
984
|
+
: (style as React.CSSProperties);
|
|
828
985
|
|
|
829
986
|
const [toggleCount, setToggleCount] = useState(0);
|
|
830
987
|
const registerToggle = useCallback(() => {
|
|
@@ -944,15 +1101,24 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
|
|
|
944
1101
|
ref={ref}
|
|
945
1102
|
className={cn(
|
|
946
1103
|
styles.topNav,
|
|
1104
|
+
sticky && styles.topNavSticky,
|
|
947
1105
|
drawerActive && styles.topNavDrawerOpen,
|
|
948
1106
|
reducedMotion && styles.topNavReducedMotion,
|
|
949
1107
|
className,
|
|
950
1108
|
)}
|
|
1109
|
+
style={mergedStyle}
|
|
951
1110
|
data-collapsed={isCollapsed || undefined}
|
|
952
1111
|
data-drawer-open={drawerActive || undefined}
|
|
1112
|
+
data-entrance={animateEntrance ? entrance : undefined}
|
|
1113
|
+
data-scroll={scrollBehavior !== 'none' ? scrollBehavior : undefined}
|
|
1114
|
+
data-scrolled={scrolled || undefined}
|
|
1115
|
+
data-hidden={hidden || undefined}
|
|
953
1116
|
{...rest}
|
|
954
1117
|
>
|
|
955
1118
|
<div className={styles.topNavInner}>{children}</div>
|
|
1119
|
+
{showProgress && (
|
|
1120
|
+
<span className={styles.topNavProgress} aria-hidden="true" />
|
|
1121
|
+
)}
|
|
956
1122
|
{drawerActive && (
|
|
957
1123
|
<div
|
|
958
1124
|
className={styles.topNavBackdrop}
|
|
@@ -972,6 +1138,7 @@ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
|
|
|
972
1138
|
Item: typeof EvoTopNavItem;
|
|
973
1139
|
Actions: typeof EvoTopNavActions;
|
|
974
1140
|
Toggle: typeof EvoTopNavToggle;
|
|
1141
|
+
Search: typeof EvoTopNavSearch;
|
|
975
1142
|
Dropdown: typeof EvoTopNavDropdown;
|
|
976
1143
|
DropdownItem: typeof EvoTopNavDropdownItem;
|
|
977
1144
|
};
|
|
@@ -981,6 +1148,7 @@ EvoTopNavMenu.displayName = 'EvoTopNav.Menu';
|
|
|
981
1148
|
EvoTopNavItem.displayName = 'EvoTopNav.Item';
|
|
982
1149
|
EvoTopNavActions.displayName = 'EvoTopNav.Actions';
|
|
983
1150
|
EvoTopNavToggle.displayName = 'EvoTopNav.Toggle';
|
|
1151
|
+
EvoTopNavSearch.displayName = 'EvoTopNav.Search';
|
|
984
1152
|
EvoTopNavDropdown.displayName = 'EvoTopNav.Dropdown';
|
|
985
1153
|
EvoTopNavDropdownItem.displayName = 'EvoTopNav.DropdownItem';
|
|
986
1154
|
(EvoTopNav as { displayName?: string }).displayName = 'EvoTopNav';
|
|
@@ -990,5 +1158,6 @@ EvoTopNav.Menu = EvoTopNavMenu;
|
|
|
990
1158
|
EvoTopNav.Item = EvoTopNavItem;
|
|
991
1159
|
EvoTopNav.Actions = EvoTopNavActions;
|
|
992
1160
|
EvoTopNav.Toggle = EvoTopNavToggle;
|
|
1161
|
+
EvoTopNav.Search = EvoTopNavSearch;
|
|
993
1162
|
EvoTopNav.Dropdown = EvoTopNavDropdown;
|
|
994
1163
|
EvoTopNav.DropdownItem = EvoTopNavDropdownItem;
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
&::after {
|
|
44
44
|
opacity: 1;
|
|
45
|
-
transform: rotate(45deg) scale(1);
|
|
45
|
+
transform: translate(-50%, -60%) rotate(45deg) scale(1);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -94,15 +94,18 @@
|
|
|
94
94
|
&::after {
|
|
95
95
|
content: '';
|
|
96
96
|
position: absolute;
|
|
97
|
-
top:
|
|
98
|
-
left:
|
|
97
|
+
top: 50%;
|
|
98
|
+
left: 50%;
|
|
99
99
|
width: 5px;
|
|
100
|
-
height:
|
|
100
|
+
height: 9px;
|
|
101
101
|
border: 2px solid $evo-primary-fg;
|
|
102
102
|
border-top: none;
|
|
103
103
|
border-left: none;
|
|
104
104
|
opacity: 0;
|
|
105
|
-
|
|
105
|
+
// Centered via translate (parent's flex-centering can't reach an absolute
|
|
106
|
+
// child). -60% Y nudges the rotated "L" optically into the middle. The
|
|
107
|
+
// centering translate is folded into both states so the scale stays put.
|
|
108
|
+
transform: translate(-50%, -60%) rotate(45deg) scale(0.5);
|
|
106
109
|
transition: opacity $transition-fast, transform $transition-fast;
|
|
107
110
|
}
|
|
108
111
|
}
|