@hyphen/hyphen-components 2.24.1 → 2.25.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.
@@ -0,0 +1,633 @@
1
+ import React, {
2
+ useCallback,
3
+ useMemo,
4
+ useState,
5
+ useEffect,
6
+ forwardRef,
7
+ } from 'react';
8
+ import { Slot } from '@radix-ui/react-slot';
9
+ import classNames from 'classnames';
10
+ import { Button } from '../Button/Button';
11
+ import { Drawer } from '../Drawer/Drawer';
12
+ import { useIsMobile } from '../../hooks/useIsMobile/useIsMobile';
13
+ import { Box } from '../Box/Box';
14
+ import { IconName } from 'src/types';
15
+ import { Icon } from '../Icon/Icon';
16
+
17
+ const SIDEBAR_COOKIE_NAME = 'sidebar:state';
18
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
19
+ const SIDEBAR_WIDTH = '16rem';
20
+ const SIDEBAR_WIDTH_ICON = '3rem';
21
+ const SIDEBAR_KEYBOARD_SHORTCUT = '[';
22
+
23
+ interface SidebarContextProps {
24
+ state: 'expanded' | 'collapsed';
25
+ open: boolean;
26
+ setOpen: (open: boolean) => void;
27
+ openMobile: boolean;
28
+ setOpenMobile: (open: boolean) => void;
29
+ isMobile: boolean;
30
+ toggleSidebar: () => void;
31
+ }
32
+
33
+ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
34
+
35
+ function useSidebar() {
36
+ const context = React.useContext(SidebarContext);
37
+ if (!context) {
38
+ throw new Error('useSidebar must be used within a SidebarProvider.');
39
+ }
40
+ return context;
41
+ }
42
+
43
+ const SidebarProvider = forwardRef<
44
+ HTMLDivElement,
45
+ React.ComponentProps<'div'> & {
46
+ defaultOpen?: boolean;
47
+ open?: boolean;
48
+ onOpenChange?: (open: boolean) => void;
49
+ }
50
+ >(
51
+ (
52
+ {
53
+ defaultOpen = true,
54
+ open: openProp,
55
+ onOpenChange: setOpenProp,
56
+ className,
57
+ style,
58
+ children,
59
+ ...props
60
+ },
61
+ ref
62
+ ) => {
63
+ const isMobile = useIsMobile();
64
+ const [openMobile, setOpenMobile] = useState(() =>
65
+ isMobile ? false : openProp ?? defaultOpen
66
+ );
67
+
68
+ // Manages sidebar open state with a fallback to internal state when openProp is not provided
69
+ const [_open, _setOpen] = useState(openProp ?? defaultOpen);
70
+ const open = openProp ?? _open;
71
+
72
+ // Update open state when openProp or isMobile changes
73
+ useEffect(() => {
74
+ if (isMobile) {
75
+ setOpenMobile(false); // Always start closed on mobile
76
+ } else {
77
+ _setOpen(openProp ?? defaultOpen); // Use desktop state
78
+ }
79
+ }, [isMobile, openProp, defaultOpen]);
80
+
81
+ const setOpen = useCallback(
82
+ (value: boolean | ((value: boolean) => boolean)) => {
83
+ const newOpenState = typeof value === 'function' ? value(open) : value;
84
+
85
+ if (newOpenState !== open) {
86
+ if (setOpenProp) {
87
+ setOpenProp(newOpenState);
88
+ } else {
89
+ _setOpen(newOpenState);
90
+ }
91
+
92
+ // Set cookie only if state changes
93
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${newOpenState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
94
+ }
95
+ },
96
+ [setOpenProp, open]
97
+ );
98
+
99
+ // Toggle sidebar based on screen type
100
+ const toggleSidebar = useCallback(() => {
101
+ isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
102
+ }, [isMobile, setOpen, setOpenMobile]);
103
+
104
+ // Keydown event handler for toggling sidebar
105
+ useEffect(() => {
106
+ const handleKeyDown = (event: KeyboardEvent) => {
107
+ if (
108
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
109
+ (event.metaKey || event.ctrlKey)
110
+ ) {
111
+ event.preventDefault();
112
+ toggleSidebar();
113
+ }
114
+ };
115
+
116
+ window.addEventListener('keydown', handleKeyDown);
117
+ return () => window.removeEventListener('keydown', handleKeyDown);
118
+ }, [toggleSidebar]);
119
+
120
+ // Assign state for data attributes
121
+ const state = open ? 'expanded' : 'collapsed';
122
+
123
+ const contextValue = useMemo<SidebarContextProps>(
124
+ () => ({
125
+ state,
126
+ open,
127
+ setOpen,
128
+ isMobile,
129
+ openMobile,
130
+ setOpenMobile,
131
+ toggleSidebar,
132
+ }),
133
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
134
+ );
135
+
136
+ return (
137
+ <SidebarContext.Provider value={contextValue}>
138
+ <div
139
+ style={
140
+ {
141
+ '--sidebar-width': SIDEBAR_WIDTH,
142
+ '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
143
+ minBlockSize: '100svh',
144
+ ...style,
145
+ } as React.CSSProperties
146
+ }
147
+ className={classNames(
148
+ 'display-flex w-100 background-color-secondary',
149
+ className
150
+ )}
151
+ ref={ref}
152
+ {...props}
153
+ >
154
+ {children}
155
+ </div>
156
+ </SidebarContext.Provider>
157
+ );
158
+ }
159
+ );
160
+ SidebarProvider.displayName = 'SidebarProvider';
161
+
162
+ const Sidebar = React.forwardRef<
163
+ HTMLDivElement,
164
+ React.ComponentProps<'div'> & {
165
+ side?: 'left'; // no right sidebar yet
166
+ collapsible?: 'offcanvas' | 'icon' | 'none';
167
+ }
168
+ >(
169
+ (
170
+ { side = 'left', collapsible = 'offcanvas', className, children, ...props },
171
+ ref
172
+ ) => {
173
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
174
+
175
+ if (isMobile) {
176
+ return (
177
+ <Drawer
178
+ isOpen={openMobile}
179
+ onDismiss={() => setOpenMobile(false)}
180
+ placement={side}
181
+ >
182
+ <Box data-sidebar="sidebar" data-mobile="true">
183
+ {children}
184
+ </Box>
185
+ </Drawer>
186
+ );
187
+ }
188
+
189
+ if (collapsible === 'none') {
190
+ return (
191
+ <div
192
+ className={classNames(
193
+ 'display-flex h-100 font-size-xs flex-direction-column background-color-secondary font-color-base',
194
+ className
195
+ )}
196
+ style={{
197
+ width: 'var(--sidebar-width)',
198
+ }}
199
+ ref={ref}
200
+ {...props}
201
+ >
202
+ {children}
203
+ </div>
204
+ );
205
+ }
206
+
207
+ return (
208
+ <Box
209
+ ref={ref}
210
+ background="primary"
211
+ display={{ base: 'none', desktop: 'block' }}
212
+ color="base"
213
+ fontSize="sm"
214
+ data-state={state}
215
+ data-collapsible={collapsible}
216
+ data-side={side}
217
+ >
218
+ <div
219
+ style={{
220
+ animationTimingFunction: 'var(--sidebar-transition-timing, linear)',
221
+ transitionTimingFunction:
222
+ 'var(--sidebar-transition-timing, linear)',
223
+ transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
224
+ animationDuration: 'var(--sidebar-transition-duration, 200ms)',
225
+ transitionProperty: 'width',
226
+ width: state === 'collapsed' ? '0' : 'var(--sidebar-width)',
227
+ height: '100svh',
228
+ }}
229
+ className={classNames('position-relative', className)}
230
+ />
231
+ <div
232
+ className={classNames(
233
+ 'position-fixed display-none display-flex-desktop ',
234
+ className
235
+ )}
236
+ style={{
237
+ left: state === 'expanded' ? '0' : 'calc(var(--sidebar-width)*-1)',
238
+ top: '0',
239
+ bottom: '0',
240
+ zIndex: 'var(--size-z-index-drawer)',
241
+ transitionProperty: 'left, right, width',
242
+ width: 'var(--sidebar-width)',
243
+ height: '100svh',
244
+ }}
245
+ {...props}
246
+ >
247
+ <div
248
+ data-sidebar="sidebar"
249
+ className="display-flex h-100 w-100 flex-direction-column background-color-secondary font-color-base"
250
+ >
251
+ {children}
252
+ </div>
253
+ </div>
254
+ </Box>
255
+ );
256
+ }
257
+ );
258
+ Sidebar.displayName = 'Sidebar';
259
+
260
+ const SidebarTrigger = React.forwardRef<
261
+ React.ElementRef<typeof Button>,
262
+ React.ComponentProps<typeof Button>
263
+ >(({ className, onClick, ...props }, ref) => {
264
+ const { toggleSidebar } = useSidebar();
265
+
266
+ return (
267
+ <Button
268
+ ref={ref}
269
+ data-sidebar="trigger"
270
+ variant="tertiary"
271
+ size="sm"
272
+ iconPrefix="dock-left"
273
+ className={classNames('m-left-sm m-left-0-tablet', className)}
274
+ onClick={(event) => {
275
+ onClick?.(event);
276
+ toggleSidebar();
277
+ }}
278
+ aria-label="toggle sidebar"
279
+ {...props}
280
+ />
281
+ );
282
+ });
283
+ SidebarTrigger.displayName = 'SidebarTrigger';
284
+
285
+ const SidebarInset = React.forwardRef<
286
+ HTMLDivElement,
287
+ React.ComponentProps<'main'>
288
+ >(({ className, ...props }, ref) => {
289
+ return (
290
+ <main
291
+ ref={ref}
292
+ className={classNames(
293
+ 'display-flex flex-auto flex-direction-column g-lg align-items-flex-start p-h-0 p-top-lg p-bottom-0 p-lg-tablet background-color-secondary',
294
+ className
295
+ )}
296
+ {...props}
297
+ />
298
+ );
299
+ });
300
+ SidebarInset.displayName = 'SidebarInset';
301
+
302
+ const SidebarHeader = React.forwardRef<
303
+ HTMLDivElement,
304
+ React.ComponentProps<'div'>
305
+ >(({ className, ...props }, ref) => {
306
+ return (
307
+ <div
308
+ ref={ref}
309
+ data-sidebar="header"
310
+ className={classNames(
311
+ 'display-flex g-sm p-v-md p-h-md p-right-0-desktop',
312
+ className
313
+ )}
314
+ {...props}
315
+ />
316
+ );
317
+ });
318
+ SidebarHeader.displayName = 'SidebarHeader';
319
+
320
+ const SidebarFooter = React.forwardRef<
321
+ HTMLDivElement,
322
+ React.ComponentProps<'div'>
323
+ >(({ className, ...props }, ref) => {
324
+ return (
325
+ <div
326
+ ref={ref}
327
+ data-sidebar="footer"
328
+ className={classNames(
329
+ 'display-flex g-sm p-v-md p-h-md p-right-0-desktop',
330
+ className
331
+ )}
332
+ {...props}
333
+ />
334
+ );
335
+ });
336
+ SidebarFooter.displayName = 'SidebarFooter';
337
+
338
+ const SidebarContent = React.forwardRef<
339
+ HTMLDivElement,
340
+ React.ComponentProps<'div'>
341
+ >(({ className, ...props }, ref) => {
342
+ return (
343
+ <div
344
+ ref={ref}
345
+ data-sidebar="content"
346
+ className={classNames(
347
+ 'display-flex flex-direction-column g-xl minh-0 flex-auto overflow-auto',
348
+ className
349
+ )}
350
+ {...props}
351
+ />
352
+ );
353
+ });
354
+ SidebarContent.displayName = 'SidebarContent';
355
+
356
+ const SidebarMenu = React.forwardRef<
357
+ HTMLUListElement,
358
+ React.ComponentProps<'ul'>
359
+ >(({ className, ...props }, ref) => (
360
+ <ul
361
+ ref={ref}
362
+ data-sidebar="menu"
363
+ className={classNames(
364
+ 'display-flex flex-direction-column w-100 minw-0 g-xs p-0 m-0',
365
+ className
366
+ )}
367
+ style={{
368
+ listStyle: 'none',
369
+ }}
370
+ {...props}
371
+ />
372
+ ));
373
+ SidebarMenu.displayName = 'SidebarMenu';
374
+
375
+ const SidebarMenuItem = React.forwardRef<
376
+ HTMLLIElement,
377
+ React.ComponentProps<'li'>
378
+ >(({ className, ...props }, ref) => (
379
+ <li
380
+ ref={ref}
381
+ data-sidebar="menu-item"
382
+ className={classNames('font-size-sm position-relative', className)}
383
+ {...props}
384
+ />
385
+ ));
386
+ SidebarMenuItem.displayName = 'SidebarMenuItem';
387
+
388
+ const SidebarMenuButton = React.forwardRef<
389
+ HTMLButtonElement,
390
+ React.ComponentProps<'button'> & {
391
+ asChild?: boolean;
392
+ isActive?: boolean;
393
+ icon?: IconName;
394
+ }
395
+ >(({ asChild = false, isActive = false, icon, className, ...props }, ref) => {
396
+ const Comp = asChild ? Slot : 'button';
397
+
398
+ const button = (
399
+ <Comp
400
+ ref={ref}
401
+ data-sidebar="menu-button"
402
+ data-active={isActive}
403
+ className={classNames(
404
+ 'display-flex w-100 flex-auto p-sm br-sm g-lg flex-direction-row flex-auto align-items-center font-size-sm bw-0 font-weight-medium text-align-left td-none hover:background-color-tertiary font-color-base cursor-pointer',
405
+ {
406
+ 'background-color-tertiary': isActive,
407
+ 'background-color-transparent': !isActive,
408
+ },
409
+ className
410
+ )}
411
+ {...props}
412
+ >
413
+ {props.children}
414
+ </Comp>
415
+ );
416
+
417
+ return button;
418
+ });
419
+ SidebarMenuButton.displayName = 'SidebarMenuButton';
420
+
421
+ const SidebarGroup = React.forwardRef<
422
+ HTMLDivElement,
423
+ React.ComponentProps<'div'>
424
+ >(({ className, ...props }, ref) => {
425
+ return (
426
+ <div
427
+ ref={ref}
428
+ data-sidebar="group"
429
+ className={classNames(
430
+ 'position-relative p-h-md p-right-0-desktop display-flex w-100 minw-0 flex-direction-column',
431
+ className
432
+ )}
433
+ {...props}
434
+ />
435
+ );
436
+ });
437
+ SidebarGroup.displayName = 'SidebarGroup';
438
+
439
+ const SidebarGroupLabel = React.forwardRef<
440
+ HTMLDivElement,
441
+ React.ComponentProps<'div'>
442
+ >(({ className, ...props }, ref) => {
443
+ return (
444
+ <div
445
+ ref={ref}
446
+ data-sidebar="group-label"
447
+ className={classNames(
448
+ 'display-flex h-3xl align-items-center br-sm p-h-sm font-color-secondary font-size-xs font-weight-medium outline-none',
449
+ className
450
+ )}
451
+ {...props}
452
+ />
453
+ );
454
+ });
455
+ SidebarGroupLabel.displayName = 'SidebarGroupLabel';
456
+
457
+ const SidebarMenuSub = React.forwardRef<
458
+ HTMLUListElement,
459
+ React.ComponentProps<'ul'>
460
+ >(({ className, ...props }, ref) => (
461
+ <ul
462
+ ref={ref}
463
+ data-sidebar="menu-sub"
464
+ className={classNames(
465
+ 'display-flex min-w-0 m-left-xl p-left-sm flex-direction-column g-2xs bw-left-sm border-color-default',
466
+ className
467
+ )}
468
+ style={{
469
+ listStyle: 'none',
470
+ }}
471
+ {...props}
472
+ />
473
+ ));
474
+ SidebarMenuSub.displayName = 'SidebarMenuSub';
475
+
476
+ const SidebarMenuSubItem = React.forwardRef<
477
+ HTMLLIElement,
478
+ React.ComponentProps<'li'>
479
+ >(({ ...props }, ref) => <li ref={ref} {...props} />);
480
+ SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
481
+
482
+ const SidebarMenuSubButton = React.forwardRef<
483
+ HTMLAnchorElement,
484
+ React.ComponentProps<'a'> & {
485
+ asChild?: boolean;
486
+ isActive?: boolean;
487
+ }
488
+ >(({ asChild = false, isActive, className, ...props }, ref) => {
489
+ const Comp = asChild ? Slot : 'a';
490
+
491
+ return (
492
+ <Comp
493
+ ref={ref}
494
+ data-sidebar="menu-sub-button"
495
+ data-active={isActive}
496
+ className={classNames(
497
+ 'display-flex td-none h-4xl p-left-lg font-color-base minw-0 align-items-center gap-sm overflow-hidden br-sm outline-none hover:background-color-tertiary',
498
+ {
499
+ 'background-color-tertiary': isActive,
500
+ 'background-color-transparent': !isActive,
501
+ },
502
+ className
503
+ )}
504
+ {...props}
505
+ />
506
+ );
507
+ });
508
+ SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
509
+
510
+ const SidebarMenuAction = React.forwardRef<
511
+ HTMLButtonElement,
512
+ React.ComponentProps<'button'> & {
513
+ asChild?: boolean;
514
+ }
515
+ >(({ className, asChild = false, ...props }, ref) => {
516
+ const Comp = asChild ? Slot : 'button';
517
+
518
+ return (
519
+ <Comp
520
+ ref={ref}
521
+ data-sidebar="menu-action"
522
+ className={classNames(
523
+ 'position-absolute lh-none p-xs font-color-secondary cursor-pointer hover:font-color-base minw-0 align-items-center bw-0 br-sm outline-none background-color-transparent hover:background-color-tertiary',
524
+ className
525
+ )}
526
+ style={{
527
+ top: 'var(--size-spacing-xs)',
528
+ right: 'var(--size-spacing-xs)',
529
+ }}
530
+ {...props}
531
+ />
532
+ );
533
+ });
534
+ SidebarMenuAction.displayName = 'SidebarMenuAction';
535
+
536
+ const SidebarRail = React.forwardRef<
537
+ HTMLButtonElement,
538
+ React.ComponentProps<'button'>
539
+ >(({ className, ...props }, ref) => {
540
+ const { open, toggleSidebar } = useSidebar();
541
+
542
+ const caretIcon = open ? 'caret-sm-left' : 'caret-sm-right';
543
+
544
+ return (
545
+ <button
546
+ ref={ref}
547
+ data-sidebar="rail"
548
+ aria-label="Toggle Sidebar"
549
+ tabIndex={-1}
550
+ onClick={toggleSidebar}
551
+ title="Toggle Sidebar"
552
+ className={classNames(
553
+ 'hover-show-child display-flex p-top-5xl justify-content-center position-absolute background-color-transparent bw-0',
554
+ {
555
+ 'cursor-w-resize': open,
556
+ 'cursor-e-resize': !open,
557
+ },
558
+ className
559
+ )}
560
+ style={{
561
+ top: '0',
562
+ bottom: '0',
563
+ right: '-1rem',
564
+ width: '1rem',
565
+ zIndex: '-1',
566
+ }}
567
+ {...props}
568
+ >
569
+ <Box
570
+ radius="xl"
571
+ background="primary"
572
+ color="secondary"
573
+ borderWidth="sm"
574
+ padding="xs"
575
+ margin="0 0 0 sm"
576
+ shadow="xs"
577
+ className={classNames(
578
+ 'hover-child',
579
+ {
580
+ 'cursor-w-resize': open,
581
+ 'cursor-e-resize': !open,
582
+ },
583
+ className
584
+ )}
585
+ >
586
+ <Icon name={caretIcon} />
587
+ </Box>
588
+ </button>
589
+ );
590
+ });
591
+ SidebarRail.displayName = 'SidebarRail';
592
+
593
+ const SidebarMenuBadge = React.forwardRef<
594
+ HTMLDivElement,
595
+ React.ComponentProps<'div'>
596
+ >(({ className, ...props }, ref) => (
597
+ <div
598
+ ref={ref}
599
+ data-sidebar="menu-badge"
600
+ className={classNames(
601
+ 'position-absolute font-size-xs cursor-default lh-none p-xs font-color-base minw-0 align-items-center bw-0 br-sm outline-none background-color-transparent',
602
+ className
603
+ )}
604
+ style={{
605
+ top: 'var(--size-spacing-sm)',
606
+ right: 'var(--size-spacing-xs)',
607
+ }}
608
+ {...props}
609
+ />
610
+ ));
611
+ SidebarMenuBadge.displayName = 'SidebarMenuBadge';
612
+
613
+ export {
614
+ Sidebar,
615
+ SidebarContent,
616
+ SidebarFooter,
617
+ SidebarGroup,
618
+ SidebarGroupLabel,
619
+ SidebarHeader,
620
+ SidebarInset,
621
+ SidebarMenu,
622
+ SidebarMenuAction,
623
+ SidebarMenuBadge,
624
+ SidebarMenuButton,
625
+ SidebarMenuItem,
626
+ SidebarMenuSub,
627
+ SidebarMenuSubButton,
628
+ SidebarMenuSubItem,
629
+ SidebarProvider,
630
+ SidebarRail,
631
+ SidebarTrigger,
632
+ useSidebar,
633
+ };
@@ -1,6 +1,6 @@
1
1
  export * from './useBreakpoint/useBreakpoint';
2
2
  export * from './useIsMobile/useIsMobile';
3
- export * from './useIsomorphicLayoutEffect/useIsomorphicLayouEffect';
3
+ export * from './useIsomorphicLayoutEffect/useIsomorphicLayoutEffect';
4
4
  export * from './useOpenClose/useOpenClose';
5
5
  export * from './useTheme/useTheme';
6
6
  export * from './useWindowSize/useWindowSize';
@@ -2,7 +2,7 @@ import { useState } from 'react';
2
2
  import { BREAKPOINTS } from '../../lib/tokens';
3
3
  import { Breakpoint } from '../../types';
4
4
  import { useWindowSize } from '../useWindowSize/useWindowSize';
5
- import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect/useIsomorphicLayouEffect';
5
+ import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect/useIsomorphicLayoutEffect';
6
6
 
7
7
  const defaultBreakpoint: Breakpoint = { name: 'base', minWidth: 0 };
8
8
 
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export * from './components/ResponsiveProvider/ResponsiveProvider';
37
37
  export * from './components/SelectInput/SelectInput';
38
38
  export * from './components/SelectInputInset/SelectInputInset';
39
39
  export * from './components/SelectInputNative/SelectInputNative';
40
+ export * from './components/Sidebar/Sidebar';
40
41
  export * from './components/Spinner/Spinner';
41
42
  export * from './components/Table/Table';
42
43
  export * from './components/TextareaInput/TextareaInput';
@@ -56,7 +56,6 @@
56
56
  }
57
57
  }
58
58
 
59
-
60
59
  @keyframes slideInUp {
61
60
  from {
62
61
  transform: translateY(100%);
@@ -8,6 +8,10 @@
8
8
  box-sizing: border-box;
9
9
  }
10
10
 
11
+ * {
12
+ border-color: var(--color-border-subtle);
13
+ }
14
+
11
15
  html,
12
16
  body {
13
17
  height: 100%;
@@ -79,6 +83,12 @@ a:not([class]) {
79
83
  text-decoration-skip-ink: auto;
80
84
  }
81
85
 
86
+ a,
87
+ a:visited {
88
+ color: inherit;
89
+ text-decoration: none;
90
+ }
91
+
82
92
  // Make images easier to work with
83
93
  img {
84
94
  max-width: 100%;