@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/dist/assets/ui.css +320 -183
- package/dist/components/Header/Header.module.scss.cjs +42 -21
- package/dist/components/Header/Header.module.scss.js +42 -21
- package/dist/components/Header/index.cjs +121 -3
- package/dist/components/Header/index.d.ts +26 -3
- package/dist/components/Header/index.d.ts.map +1 -1
- package/dist/components/Header/index.js +122 -4
- package/dist/components/Sidebar/Sidebar.module.scss.cjs +42 -42
- package/dist/components/Sidebar/Sidebar.module.scss.js +42 -42
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/fragments.json +1 -1
- package/package.json +1 -1
- package/src/components/Header/Header.module.scss +99 -0
- package/src/components/Header/index.tsx +191 -10
- package/src/components/Sidebar/Sidebar.module.scss +6 -4
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
<
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
{
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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-
|
|
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
|
|