@justin_evo/evo-ui 1.0.1 → 1.1.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 -0
- package/dist/Nav/Nav.d.ts +15 -0
- package/dist/evo-ui.css +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +2719 -2680
- package/package.json +1 -1
- package/src/Nav/Nav.tsx +96 -14
- package/src/css/nav.module.scss +170 -15
- package/src/index.ts +1 -1
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)
|
package/src/css/nav.module.scss
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
color: $color-text-secondary;
|
|
57
57
|
background: transparent;
|
|
58
58
|
border: none;
|
|
59
|
-
border-radius: $radius
|
|
59
|
+
border-radius: $evo-border-radius;
|
|
60
60
|
cursor: pointer;
|
|
61
61
|
text-align: left;
|
|
62
62
|
text-decoration: none;
|
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
&:hover {
|
|
71
71
|
background-color: $color-surface-hover;
|
|
72
72
|
color: $color-text-primary;
|
|
73
|
+
|
|
74
|
+
.navIcon {
|
|
75
|
+
color: $color-text-primary;
|
|
76
|
+
transform: translateX(1px);
|
|
77
|
+
}
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
&:focus-visible {
|
|
@@ -80,16 +85,10 @@
|
|
|
80
85
|
&.active {
|
|
81
86
|
background-color: $evo-primary-soft;
|
|
82
87
|
color: $evo-primary-color;
|
|
88
|
+
font-weight: 600;
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
position: absolute;
|
|
87
|
-
left: 0;
|
|
88
|
-
top: 25%;
|
|
89
|
-
bottom: 25%;
|
|
90
|
-
width: 2px;
|
|
91
|
-
border-radius: $radius-full;
|
|
92
|
-
background: $evo-primary-color;
|
|
90
|
+
.navIcon {
|
|
91
|
+
color: $evo-primary-color;
|
|
93
92
|
}
|
|
94
93
|
}
|
|
95
94
|
|
|
@@ -121,6 +120,20 @@
|
|
|
121
120
|
overflow: hidden;
|
|
122
121
|
text-overflow: ellipsis;
|
|
123
122
|
white-space: nowrap;
|
|
123
|
+
clip-path: inset(0 0 0 0);
|
|
124
|
+
transition:
|
|
125
|
+
opacity 200ms ease,
|
|
126
|
+
transform 220ms ease,
|
|
127
|
+
clip-path 260ms ease;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// When the rail expands, labels reveal top-to-bottom (per-row delay) and
|
|
131
|
+
// left-to-right (clip-path sweep). Only opacity/transform/clip-path transition,
|
|
132
|
+
// so the delay is invisible in the steady expanded state.
|
|
133
|
+
@for $i from 1 through 12 {
|
|
134
|
+
.navList .navLi:nth-child(#{$i}) .navLabel {
|
|
135
|
+
transition-delay: #{($i - 1) * 14}ms;
|
|
136
|
+
}
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
.navIcon {
|
|
@@ -132,6 +145,15 @@
|
|
|
132
145
|
height: 1.125rem;
|
|
133
146
|
font-size: 1rem;
|
|
134
147
|
line-height: 1;
|
|
148
|
+
color: $color-text-muted;
|
|
149
|
+
transition:
|
|
150
|
+
color $transition-fast,
|
|
151
|
+
transform $transition-fast;
|
|
152
|
+
|
|
153
|
+
svg {
|
|
154
|
+
width: 1.05rem;
|
|
155
|
+
height: 1.05rem;
|
|
156
|
+
}
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
.chevron {
|
|
@@ -161,18 +183,96 @@
|
|
|
161
183
|
|
|
162
184
|
.navGroup {
|
|
163
185
|
list-style: none;
|
|
164
|
-
margin: 0.
|
|
186
|
+
margin: 0.85rem 0 0;
|
|
165
187
|
padding: 0;
|
|
188
|
+
|
|
189
|
+
&:first-child {
|
|
190
|
+
margin-top: 0.25rem;
|
|
191
|
+
}
|
|
166
192
|
}
|
|
167
193
|
|
|
168
194
|
.navGroupLabel {
|
|
169
|
-
display:
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
width: 100%;
|
|
170
198
|
font-size: $text-xs;
|
|
171
|
-
font-weight:
|
|
199
|
+
font-weight: 700;
|
|
172
200
|
text-transform: uppercase;
|
|
173
|
-
letter-spacing: 0.
|
|
201
|
+
letter-spacing: 0.08em;
|
|
174
202
|
color: $color-text-muted;
|
|
175
|
-
padding: 0.
|
|
203
|
+
padding: 0.4rem 0.75rem 0.3rem;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.navGroupLabelText {
|
|
207
|
+
flex: 1;
|
|
208
|
+
min-width: 0;
|
|
209
|
+
text-align: left;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
text-overflow: ellipsis;
|
|
212
|
+
white-space: nowrap;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Collapsible group heading — same look as the static label, plus button chrome.
|
|
216
|
+
.navGroupToggle {
|
|
217
|
+
background: transparent;
|
|
218
|
+
border: none;
|
|
219
|
+
border-radius: $radius-sm;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
font-family: inherit;
|
|
222
|
+
transition: color $transition-fast;
|
|
223
|
+
|
|
224
|
+
&:hover {
|
|
225
|
+
color: $color-text-secondary;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
&:focus-visible {
|
|
229
|
+
outline: $evo-btn-outline-width solid $evo-primary-focus;
|
|
230
|
+
outline-offset: $evo-btn-outline-offset;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.chevron {
|
|
234
|
+
margin-left: 0.4rem;
|
|
235
|
+
color: $color-text-muted;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.navGroupCount {
|
|
240
|
+
display: inline-flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
justify-content: center;
|
|
243
|
+
min-width: 1.15rem;
|
|
244
|
+
height: 1.15rem;
|
|
245
|
+
padding: 0 0.35rem;
|
|
246
|
+
margin-left: 0.4rem;
|
|
247
|
+
font-size: 0.65rem;
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
letter-spacing: 0;
|
|
250
|
+
color: $color-text-secondary;
|
|
251
|
+
background: $color-surface-hover;
|
|
252
|
+
border-radius: $radius-full;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Accordion panel — animates height via grid-template-rows (0fr <-> 1fr), no
|
|
256
|
+
// JS measuring. The inner list must clip its overflow for the collapse to read.
|
|
257
|
+
.navGroupPanel {
|
|
258
|
+
display: grid;
|
|
259
|
+
grid-template-rows: 1fr;
|
|
260
|
+
transition: grid-template-rows 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
261
|
+
|
|
262
|
+
&[data-open='false'] {
|
|
263
|
+
grid-template-rows: 0fr;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
> .navList {
|
|
267
|
+
min-height: 0;
|
|
268
|
+
overflow: hidden;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Rows inside the clipped panel draw their focus ring INSET, so the
|
|
272
|
+
// overflow:hidden needed for the height animation can't clip it.
|
|
273
|
+
.navRow:focus-visible {
|
|
274
|
+
outline-offset: -2px;
|
|
275
|
+
}
|
|
176
276
|
}
|
|
177
277
|
|
|
178
278
|
// ---------- Skeleton ----------
|
|
@@ -321,10 +421,65 @@
|
|
|
321
421
|
to { opacity: 1; }
|
|
322
422
|
}
|
|
323
423
|
|
|
424
|
+
// ---------- Collapsed (icon-only rail) ----------
|
|
425
|
+
|
|
426
|
+
.navCollapsed {
|
|
427
|
+
.navRow,
|
|
428
|
+
.navQuickAction {
|
|
429
|
+
justify-content: center;
|
|
430
|
+
gap: 0;
|
|
431
|
+
padding-left: 0.5rem;
|
|
432
|
+
padding-right: 0.5rem;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.navLabel {
|
|
436
|
+
flex: 0;
|
|
437
|
+
opacity: 0;
|
|
438
|
+
transform: translateX(-4px);
|
|
439
|
+
clip-path: inset(0 100% 0 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Group heading text stays in the a11y tree (it names the heading) but is
|
|
443
|
+
// visually hidden; the decorative count chip is removed entirely.
|
|
444
|
+
.navGroupLabelText {
|
|
445
|
+
position: absolute;
|
|
446
|
+
width: 1px;
|
|
447
|
+
height: 1px;
|
|
448
|
+
padding: 0;
|
|
449
|
+
margin: -1px;
|
|
450
|
+
overflow: hidden;
|
|
451
|
+
clip-path: inset(50%);
|
|
452
|
+
white-space: nowrap;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.navGroupCount {
|
|
456
|
+
display: none;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.navGroupLabel {
|
|
460
|
+
min-height: 0.5rem;
|
|
461
|
+
padding: 0.25rem 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Accordion is meaningless in rail mode — force panels open.
|
|
465
|
+
.navGroupPanel {
|
|
466
|
+
grid-template-rows: 1fr;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Collapsing fades labels out together — outrank the per-row expand stagger.
|
|
471
|
+
.navCollapsed .navList .navLi .navLabel {
|
|
472
|
+
transition-delay: 0ms;
|
|
473
|
+
}
|
|
474
|
+
|
|
324
475
|
// ---------- Reduced motion ----------
|
|
325
476
|
|
|
326
477
|
@media (prefers-reduced-motion: reduce) {
|
|
327
478
|
.navRow,
|
|
479
|
+
.navRow .navIcon,
|
|
480
|
+
.navLabel,
|
|
481
|
+
.navGroupPanel,
|
|
482
|
+
.navGroupToggle,
|
|
328
483
|
.navQuickAction,
|
|
329
484
|
.chevron,
|
|
330
485
|
.navTrigger,
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module EvoUI
|
|
3
3
|
* @summary A high-performance, enterprise-grade UI component library built for modern web applications.
|
|
4
|
-
* @version 1.0.
|
|
4
|
+
* @version 1.0.2
|
|
5
5
|
* @author Justin Khor
|
|
6
6
|
* @license MIT
|
|
7
7
|
* @see {@link https://github.com/your-repo/evo-ui} Official Documentation
|