@justin_evo/evo-ui 1.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justin_evo/evo-ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -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?.focus();
506
- const html = `<img src="${src.replace(/"/g, '&quot;')}" alt="${alt.replace(/"/g, '&quot;')}" />`;
507
- execCommand('insertHTML', html);
505
+ const el = editorRef.current;
506
+ if (!el) return;
507
+ el.focus();
508
+ const safeSrc = src.replace(/"/g, '&quot;');
509
+ const safeAlt = alt.replace(/"/g, '&quot;');
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
 
@@ -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: 0px;
98
- left: 3px;
97
+ top: 50%;
98
+ left: 50%;
99
99
  width: 5px;
100
- height: 8px;
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
- transform: rotate(45deg) scale(0.5);
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
  }
@@ -20,6 +20,16 @@
20
20
  to { opacity: 1; transform: translateY(0); }
21
21
  }
22
22
 
23
+ @keyframes topNavRise {
24
+ from { opacity: 0; transform: translateY(0.5rem); }
25
+ to { opacity: 1; transform: translateY(0); }
26
+ }
27
+
28
+ @keyframes topNavFade {
29
+ from { opacity: 0; }
30
+ to { opacity: 1; }
31
+ }
32
+
23
33
  // ---------------------------------------------------------------------------
24
34
  // Root
25
35
  // ---------------------------------------------------------------------------
@@ -159,6 +169,61 @@
159
169
  flex-shrink: 0;
160
170
  }
161
171
 
172
+ // ---------------------------------------------------------------------------
173
+ // Search trigger (EvoTopNav.Search) — presentational ⌘K affordance
174
+ // ---------------------------------------------------------------------------
175
+
176
+ .topNavSearch {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: 0.5rem;
180
+ height: 2rem;
181
+ min-width: 12rem;
182
+ padding: 0 0.625rem;
183
+ font-family: inherit;
184
+ font-size: $text-sm;
185
+ color: $color-text-muted;
186
+ background-color: $color-surface-sunken;
187
+ border: 1px solid $color-border;
188
+ border-radius: $radius-sm;
189
+ cursor: pointer;
190
+ transition:
191
+ background-color $transition-fast,
192
+ border-color $transition-fast,
193
+ color $transition-fast;
194
+
195
+ &:hover {
196
+ background-color: $color-surface-hover;
197
+ color: $color-text-secondary;
198
+ }
199
+
200
+ &:focus-visible {
201
+ outline: 2px solid $evo-primary-focus;
202
+ outline-offset: 2px;
203
+ }
204
+ }
205
+
206
+ .topNavSearchIcon {
207
+ display: inline-flex;
208
+ flex-shrink: 0;
209
+ }
210
+
211
+ .topNavSearchText {
212
+ flex: 1;
213
+ text-align: left;
214
+ }
215
+
216
+ .topNavSearchKbd {
217
+ flex-shrink: 0;
218
+ font-family: inherit;
219
+ font-size: $text-xs;
220
+ color: $color-text-muted;
221
+ background-color: $color-surface;
222
+ border: 1px solid $color-border;
223
+ border-radius: 4px;
224
+ padding: 0.0625rem 0.3125rem;
225
+ }
226
+
162
227
  // ---------------------------------------------------------------------------
163
228
  // Toggle (hamburger) — hidden above the collapse breakpoint
164
229
  // ---------------------------------------------------------------------------
@@ -360,6 +425,17 @@
360
425
  z-index: 55;
361
426
  animation: topNavOverlayFadeIn 180ms ease;
362
427
  }
428
+
429
+ .topNavSearch {
430
+ min-width: 0;
431
+ width: 2.75rem;
432
+ height: 2.75rem;
433
+ justify-content: center;
434
+ padding: 0;
435
+
436
+ .topNavSearchText,
437
+ .topNavSearchKbd { display: none; }
438
+ }
363
439
  }
364
440
 
365
441
  // Above the breakpoint, drawer-specific bits never render visually.
@@ -371,6 +447,85 @@
371
447
  }
372
448
  }
373
449
 
450
+ // ---------------------------------------------------------------------------
451
+ // Entrance animation (opt-in via `entrance` prop → data-entrance attribute)
452
+ // ---------------------------------------------------------------------------
453
+
454
+ .topNav[data-entrance='rise'] .topNavBrand,
455
+ .topNav[data-entrance='rise'] .topNavMenu > li,
456
+ .topNav[data-entrance='rise'] .topNavSearch,
457
+ .topNav[data-entrance='rise'] .topNavActions {
458
+ animation: topNavRise 440ms cubic-bezier(0.22, 1, 0.36, 1) both;
459
+ }
460
+
461
+ .topNav[data-entrance='fade'] .topNavBrand,
462
+ .topNav[data-entrance='fade'] .topNavMenu > li,
463
+ .topNav[data-entrance='fade'] .topNavSearch,
464
+ .topNav[data-entrance='fade'] .topNavActions {
465
+ animation: topNavFade 440ms ease both;
466
+ }
467
+
468
+ // Shared left-to-right stagger (applies to both variants).
469
+ .topNav[data-entrance] .topNavBrand { animation-delay: 40ms; }
470
+ .topNav[data-entrance] .topNavMenu > li:nth-child(1) { animation-delay: 110ms; }
471
+ .topNav[data-entrance] .topNavMenu > li:nth-child(2) { animation-delay: 160ms; }
472
+ .topNav[data-entrance] .topNavMenu > li:nth-child(3) { animation-delay: 210ms; }
473
+ .topNav[data-entrance] .topNavMenu > li:nth-child(4) { animation-delay: 260ms; }
474
+ .topNav[data-entrance] .topNavMenu > li:nth-child(n + 5) { animation-delay: 300ms; }
475
+ .topNav[data-entrance] .topNavSearch { animation-delay: 320ms; }
476
+ .topNav[data-entrance] .topNavActions { animation-delay: 360ms; }
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Sticky + scroll-aware behavior (opt-in via `sticky` / `scrollBehavior`)
480
+ // ---------------------------------------------------------------------------
481
+
482
+ .topNavSticky {
483
+ position: sticky;
484
+ top: 0;
485
+ z-index: 30;
486
+ }
487
+
488
+ .topNav[data-scroll] {
489
+ transition:
490
+ background-color $transition-fast,
491
+ box-shadow $transition-fast,
492
+ transform 220ms ease;
493
+
494
+ .topNavInner { transition: min-height $transition-fast; }
495
+ }
496
+
497
+ .topNav[data-scrolled] {
498
+ background-color: $color-surface; // fallback for browsers without color-mix()
499
+ background-color: color-mix(in srgb, $color-surface 85%, transparent);
500
+ -webkit-backdrop-filter: blur(10px);
501
+ backdrop-filter: blur(10px);
502
+ box-shadow: $shadow-md;
503
+ }
504
+
505
+ .topNav[data-scroll='shrink'][data-scrolled] .topNavInner {
506
+ min-height: 2.75rem;
507
+ }
508
+
509
+ .topNav[data-scroll='hide'] { will-change: transform; }
510
+ .topNav[data-scroll='hide'][data-hidden] { transform: translateY(-100%); }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Scroll-progress line (opt-in via `showProgress`)
514
+ // ---------------------------------------------------------------------------
515
+
516
+ .topNavProgress {
517
+ position: absolute;
518
+ left: 0;
519
+ bottom: 0;
520
+ width: 100%;
521
+ height: 2px;
522
+ background: $evo-primary-color;
523
+ transform: scaleX(var(--evo-topnav-progress, 0));
524
+ transform-origin: left center;
525
+ pointer-events: none;
526
+ z-index: 31;
527
+ }
528
+
374
529
  // ---------------------------------------------------------------------------
375
530
  // Reduced motion — kill animations
376
531
  // ---------------------------------------------------------------------------
@@ -385,6 +540,16 @@
385
540
  .topNavDropdownChevron {
386
541
  transition: none;
387
542
  }
543
+
544
+ .topNavBrand,
545
+ .topNavMenu > li,
546
+ .topNavSearch,
547
+ .topNavActions {
548
+ animation: none !important;
549
+ }
550
+
551
+ &[data-scroll='hide'][data-hidden] { transition: none; }
552
+ .topNavProgress { transition: none; }
388
553
  }
389
554
 
390
555
  @media (prefers-reduced-motion: reduce) {
@@ -393,4 +558,11 @@
393
558
  .topNavBackdrop {
394
559
  animation: none;
395
560
  }
561
+
562
+ .topNav[data-entrance] .topNavBrand,
563
+ .topNav[data-entrance] .topNavMenu > li,
564
+ .topNav[data-entrance] .topNavSearch,
565
+ .topNav[data-entrance] .topNavActions {
566
+ animation: none !important;
567
+ }
396
568
  }