@fragments-sdk/ui 0.17.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fragments-sdk/ui",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "description": "Customizable UI components built on Base UI headless primitives",
6
6
  "author": "Conan McNicholl",
@@ -297,6 +297,105 @@
297
297
  flex: 1;
298
298
  }
299
299
 
300
+ // ============================================
301
+ // Mobile Nav Drawer
302
+ // ============================================
303
+
304
+ .mobileNavBackdrop {
305
+ position: fixed;
306
+ inset: 0;
307
+ z-index: 98;
308
+ background: rgba(0, 0, 0, 0.5);
309
+ animation: mobileNavFadeIn var(--fui-transition-normal, $fui-transition-normal) ease;
310
+ }
311
+
312
+ .mobileNavDrawer {
313
+ position: fixed;
314
+ top: 0;
315
+ right: 0;
316
+ bottom: 0;
317
+ z-index: 99;
318
+ width: min(320px, 85vw);
319
+ background-color: var(--fui-bg-primary, $fui-bg-primary);
320
+ border-left: 1px solid var(--fui-border, $fui-border);
321
+ display: flex;
322
+ flex-direction: column;
323
+ animation: mobileNavSlideIn var(--fui-transition-normal, $fui-transition-normal) ease;
324
+ }
325
+
326
+ .mobileNavHeader {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: flex-end;
330
+ padding: var(--fui-space-3, $fui-space-3) var(--fui-space-4, $fui-space-4);
331
+ border-bottom: 1px solid var(--fui-border, $fui-border);
332
+ }
333
+
334
+ .mobileNavClose {
335
+ @include button-reset;
336
+ @include interactive-base;
337
+
338
+ display: flex;
339
+ align-items: center;
340
+ justify-content: center;
341
+ width: var(--fui-touch-md, $fui-touch-md);
342
+ height: var(--fui-touch-md, $fui-touch-md);
343
+ border-radius: var(--fui-radius-md, $fui-radius-md);
344
+ color: var(--fui-text-secondary, $fui-text-secondary);
345
+
346
+ &:hover {
347
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
348
+ color: var(--fui-text-primary, $fui-text-primary);
349
+ }
350
+ }
351
+
352
+ .mobileNavBody {
353
+ flex: 1;
354
+ overflow-y: auto;
355
+ padding: var(--fui-space-3, $fui-space-3) var(--fui-space-4, $fui-space-4);
356
+ }
357
+
358
+ .mobileNavLink {
359
+ @include button-reset;
360
+ @include text-base;
361
+
362
+ display: flex;
363
+ align-items: center;
364
+ width: 100%;
365
+ padding: var(--fui-space-2, $fui-space-2) var(--fui-space-3, $fui-space-3);
366
+ border-radius: var(--fui-radius-md, $fui-radius-md);
367
+ color: var(--fui-text-secondary, $fui-text-secondary);
368
+ text-decoration: none;
369
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
370
+
371
+ &:hover {
372
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
373
+ color: var(--fui-text-primary, $fui-text-primary);
374
+ }
375
+ }
376
+
377
+ .mobileNavLinkActive {
378
+ color: var(--fui-text-primary, $fui-text-primary);
379
+ background-color: var(--fui-bg-secondary, $fui-bg-secondary);
380
+ }
381
+
382
+ @keyframes mobileNavFadeIn {
383
+ from { opacity: 0; }
384
+ to { opacity: 1; }
385
+ }
386
+
387
+ @keyframes mobileNavSlideIn {
388
+ from { transform: translateX(100%); }
389
+ to { transform: translateX(0); }
390
+ }
391
+
392
+ @media (prefers-reduced-motion: reduce) {
393
+ .mobileNavBackdrop,
394
+ .mobileNavDrawer {
395
+ animation: none;
396
+ }
397
+ }
398
+
300
399
  // ============================================
301
400
  // Skip Link (Accessibility)
302
401
  // ============================================
@@ -1,8 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { Menu as BaseMenu } from '@base-ui/react/menu';
5
6
  import { CaretDown, List, X } from '@phosphor-icons/react';
7
+ import { useFocusTrap } from '../../utils/a11y';
8
+ import { ScrollArea } from '../ScrollArea';
6
9
  import styles from './Header.module.scss';
7
10
  import { useSidebar } from '../Sidebar';
8
11
 
@@ -11,7 +14,7 @@ import { useSidebar } from '../Sidebar';
11
14
  // ============================================
12
15
 
13
16
  export interface HeaderIconRenderState {
14
- slot: 'menu' | 'close' | 'navMenuChevron';
17
+ slot: 'menu' | 'close' | 'navMenuChevron' | 'mobileClose';
15
18
  open?: boolean;
16
19
  active?: boolean;
17
20
  }
@@ -91,6 +94,33 @@ export interface HeaderNavMenuItemProps extends React.HTMLAttributes<HTMLElement
91
94
  asChild?: boolean;
92
95
  }
93
96
 
97
+ export interface HeaderMobileNavProps {
98
+ /** Content rendered inside the mobile drawer */
99
+ children: React.ReactNode;
100
+ /** Optional className for the drawer panel */
101
+ className?: string;
102
+ }
103
+
104
+ // ============================================
105
+ // Internal Context
106
+ // ============================================
107
+
108
+ interface HeaderContextValue {
109
+ mobileOpen: boolean;
110
+ setMobileOpen: (open: boolean) => void;
111
+ icons?: HeaderIcons;
112
+ }
113
+
114
+ const HeaderContext = React.createContext<HeaderContextValue | null>(null);
115
+
116
+ function useHeaderContext(): HeaderContextValue {
117
+ const ctx = React.useContext(HeaderContext);
118
+ if (!ctx) {
119
+ throw new Error('Header compound components must be used within a Header');
120
+ }
121
+ return ctx;
122
+ }
123
+
94
124
  // ============================================
95
125
  // Hooks
96
126
  // ============================================
@@ -150,6 +180,8 @@ function HeaderRoot({
150
180
  style: styleProp,
151
181
  ...htmlProps
152
182
  }: HeaderProps) {
183
+ const [mobileOpen, setMobileOpen] = React.useState(false);
184
+
153
185
  const classes = [
154
186
  styles.header,
155
187
  position === 'fixed' && styles.fixed,
@@ -162,14 +194,21 @@ function HeaderRoot({
162
194
  ...styleProp,
163
195
  } as React.CSSProperties;
164
196
 
197
+ const contextValue = React.useMemo(
198
+ () => ({ mobileOpen, setMobileOpen, icons }),
199
+ [mobileOpen, icons]
200
+ );
201
+
165
202
  return (
166
- <HeaderIconContext.Provider value={icons}>
167
- <header {...htmlProps} className={classes} style={style} data-position={position}>
168
- <div className={styles.container}>
169
- {children}
170
- </div>
171
- </header>
172
- </HeaderIconContext.Provider>
203
+ <HeaderContext.Provider value={contextValue}>
204
+ <HeaderIconContext.Provider value={icons}>
205
+ <header {...htmlProps} className={classes} style={style} data-position={position}>
206
+ <div className={styles.container}>
207
+ {children}
208
+ </div>
209
+ </header>
210
+ </HeaderIconContext.Provider>
211
+ </HeaderContext.Provider>
173
212
  );
174
213
  }
175
214
 
@@ -297,7 +336,11 @@ function HeaderActions({ children, className, ...htmlProps }: HeaderActionsProps
297
336
  }
298
337
 
299
338
  /**
300
- * Header.Trigger - Mobile menu trigger (integrates with SidebarProvider)
339
+ * Header.Trigger - Mobile menu trigger
340
+ *
341
+ * Works in two modes:
342
+ * 1. With SidebarProvider — toggles sidebar open state (existing behavior)
343
+ * 2. Standalone — toggles Header's internal mobile nav drawer
301
344
  */
302
345
  function HeaderTrigger({
303
346
  children,
@@ -307,7 +350,8 @@ function HeaderTrigger({
307
350
  ...htmlProps
308
351
  }: HeaderTriggerProps) {
309
352
  const isMobile = useIsMobile();
310
- const { open, setOpen } = useSidebar();
353
+ const sidebar = useSidebar();
354
+ const headerCtx = React.useContext(HeaderContext);
311
355
  const icons = useHeaderIcons();
312
356
 
313
357
  // Only render on mobile
@@ -315,6 +359,16 @@ function HeaderTrigger({
315
359
  return null;
316
360
  }
317
361
 
362
+ // Determine which state to use: sidebar (if available with a real provider) or header internal
363
+ const hasSidebarProvider = sidebar.open !== undefined && sidebar.setOpen !== undefined
364
+ && typeof sidebar.setOpen === 'function';
365
+ const isUsingSidebar = hasSidebarProvider && sidebar.isMobile;
366
+
367
+ const open = isUsingSidebar ? sidebar.open : (headerCtx?.mobileOpen ?? false);
368
+ const setOpen = isUsingSidebar
369
+ ? sidebar.setOpen
370
+ : (headerCtx?.setMobileOpen ?? (() => {}));
371
+
318
372
  const classes = [styles.trigger, className].filter(Boolean).join(' ');
319
373
  const iconSlot = open ? icons?.close : icons?.menu;
320
374
  const iconState: HeaderIconRenderState = { slot: open ? 'close' : 'menu', open };
@@ -427,6 +481,129 @@ function HeaderNavMenuItem({
427
481
  );
428
482
  }
429
483
 
484
+ /**
485
+ * Header.MobileNav - Mobile navigation drawer
486
+ *
487
+ * Renders a full-screen slide-in drawer on mobile when the Header.Trigger is toggled.
488
+ * Place navigation links, actions, or any content as children.
489
+ */
490
+ function HeaderMobileNav({ children, className }: HeaderMobileNavProps) {
491
+ const { mobileOpen, setMobileOpen, icons } = useHeaderContext();
492
+ const drawerRef = React.useRef<HTMLDivElement>(null);
493
+
494
+ useFocusTrap(drawerRef, mobileOpen);
495
+
496
+ // Lock body scroll when open
497
+ React.useEffect(() => {
498
+ if (!mobileOpen) return;
499
+ document.body.style.overflow = 'hidden';
500
+ return () => { document.body.style.overflow = ''; };
501
+ }, [mobileOpen]);
502
+
503
+ // Handle Escape
504
+ React.useEffect(() => {
505
+ if (!mobileOpen) return;
506
+ const handleKeyDown = (e: KeyboardEvent) => {
507
+ if (e.key === 'Escape') setMobileOpen(false);
508
+ };
509
+ document.addEventListener('keydown', handleKeyDown);
510
+ return () => document.removeEventListener('keydown', handleKeyDown);
511
+ }, [mobileOpen, setMobileOpen]);
512
+
513
+ if (!mobileOpen) return null;
514
+ if (typeof document === 'undefined') return null;
515
+
516
+ const closeIcon = renderHeaderIcon(icons?.mobileClose, {
517
+ slot: 'mobileClose',
518
+ open: true,
519
+ });
520
+
521
+ const drawerContent = (
522
+ <>
523
+ <div
524
+ className={styles.mobileNavBackdrop}
525
+ onClick={() => setMobileOpen(false)}
526
+ aria-hidden
527
+ />
528
+ <div
529
+ ref={drawerRef}
530
+ className={[styles.mobileNavDrawer, className].filter(Boolean).join(' ')}
531
+ role="dialog"
532
+ aria-modal
533
+ aria-label="Navigation"
534
+ >
535
+ <div className={styles.mobileNavHeader}>
536
+ <button
537
+ type="button"
538
+ className={styles.mobileNavClose}
539
+ onClick={() => setMobileOpen(false)}
540
+ aria-label="Close navigation"
541
+ >
542
+ {closeIcon ?? <X size={20} aria-hidden />}
543
+ </button>
544
+ </div>
545
+ <ScrollArea orientation="vertical" className={styles.mobileNavBody}>
546
+ {children}
547
+ </ScrollArea>
548
+ </div>
549
+ </>
550
+ );
551
+
552
+ return createPortal(drawerContent, document.body);
553
+ }
554
+
555
+ /**
556
+ * Header.MobileNavLink - A link inside the mobile drawer
557
+ */
558
+ function HeaderMobileNavLink({
559
+ children,
560
+ href,
561
+ active = false,
562
+ asChild = false,
563
+ onClick,
564
+ className,
565
+ ...htmlProps
566
+ }: HeaderNavItemProps) {
567
+ const { setMobileOpen } = useHeaderContext();
568
+
569
+ const classes = [
570
+ styles.mobileNavLink,
571
+ active && styles.mobileNavLinkActive,
572
+ className,
573
+ ].filter(Boolean).join(' ');
574
+
575
+ const handleClick: React.MouseEventHandler<HTMLElement> = (e) => {
576
+ onClick?.(e);
577
+ if (!e.defaultPrevented) setMobileOpen(false);
578
+ };
579
+
580
+ if (asChild && React.isValidElement(children)) {
581
+ const childProps = children.props as {
582
+ className?: string;
583
+ onClick?: React.MouseEventHandler<HTMLElement>;
584
+ };
585
+ return React.cloneElement(children, {
586
+ ...htmlProps,
587
+ className: [classes, childProps.className].filter(Boolean).join(' '),
588
+ onClick: composeEventHandlers(childProps.onClick, handleClick),
589
+ } as React.HTMLAttributes<HTMLElement>);
590
+ }
591
+
592
+ if (href) {
593
+ return (
594
+ <a {...htmlProps} href={href} className={classes} onClick={handleClick}>
595
+ {children}
596
+ </a>
597
+ );
598
+ }
599
+
600
+ return (
601
+ <button {...htmlProps} type="button" className={classes} onClick={handleClick}>
602
+ {children}
603
+ </button>
604
+ );
605
+ }
606
+
430
607
  /**
431
608
  * Header.SkipLink - Skip to main content link (accessibility)
432
609
  */
@@ -462,6 +639,8 @@ export const Header = Object.assign(HeaderRoot, {
462
639
  Trigger: HeaderTrigger,
463
640
  Spacer: HeaderSpacer,
464
641
  SkipLink: HeaderSkipLink,
642
+ MobileNav: HeaderMobileNav,
643
+ MobileNavLink: HeaderMobileNavLink,
465
644
  });
466
645
 
467
646
  export {
@@ -476,4 +655,6 @@ export {
476
655
  HeaderTrigger,
477
656
  HeaderSpacer,
478
657
  HeaderSkipLink,
658
+ HeaderMobileNav,
659
+ HeaderMobileNavLink,
479
660
  };
@@ -226,6 +226,7 @@
226
226
 
227
227
  .itemWrapper {
228
228
  list-style: none;
229
+ width: 100%;
229
230
  }
230
231
 
231
232
  .item {
@@ -235,8 +236,9 @@
235
236
 
236
237
  display: flex;
237
238
  align-items: center;
238
- gap: var(--fui-space-3, $fui-space-3);
239
- padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
239
+ width: 100%;
240
+ gap: var(--fui-sidebar-item-gap, var(--fui-space-2, $fui-space-2));
241
+ padding: var(--fui-sidebar-item-padding-y, var(--fui-space-1, $fui-space-1)) var(--fui-sidebar-item-padding-x, var(--fui-space-2, $fui-space-2));
240
242
  border-radius: var(--fui-radius-md, $fui-radius-md);
241
243
  color: var(--fui-text-secondary, $fui-text-secondary);
242
244
  text-decoration: none;
@@ -635,8 +637,8 @@
635
637
  .skeletonItem {
636
638
  display: flex;
637
639
  align-items: center;
638
- gap: var(--fui-space-3, $fui-space-3);
639
- padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
640
+ gap: var(--fui-sidebar-item-gap, var(--fui-space-2, $fui-space-2));
641
+ padding: var(--fui-sidebar-item-padding-y, var(--fui-space-1, $fui-space-1)) var(--fui-sidebar-item-padding-x, var(--fui-space-2, $fui-space-2));
640
642
  min-height: var(--fui-sidebar-item-height, $fui-sidebar-item-height);
641
643
  }
642
644
 
package/src/index.ts CHANGED
@@ -335,6 +335,7 @@ export {
335
335
  type HeaderSearchProps,
336
336
  type HeaderActionsProps,
337
337
  type HeaderTriggerProps,
338
+ type HeaderMobileNavProps,
338
339
  } from './components/Header';
339
340
 
340
341
  // AppShell