@justin_evo/evo-ui 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justin_evo/evo-ui",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "types": "dist/index.d.ts",
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
- ref={ref}
460
- className={[styles.navGroup, className].filter(Boolean).join(' ')}
461
- >
462
- <div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
463
- {label}
464
- </div>
465
- <ul role="group" aria-labelledby={headingId} className={styles.navList}>
466
- {children}
467
- </ul>
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)
@@ -56,7 +56,7 @@
56
56
  color: $color-text-secondary;
57
57
  background: transparent;
58
58
  border: none;
59
- border-radius: $radius-sm;
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
- &::before {
85
- content: '';
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.5rem 0 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: block;
195
+ display: flex;
196
+ align-items: center;
197
+ width: 100%;
170
198
  font-size: $text-xs;
171
- font-weight: 600;
199
+ font-weight: 700;
172
200
  text-transform: uppercase;
173
- letter-spacing: 0.06em;
201
+ letter-spacing: 0.08em;
174
202
  color: $color-text-muted;
175
- padding: 0.5rem 0.75rem 0.25rem;
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,