@hyphen/hyphen-components 7.2.0 → 7.3.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.
@@ -25,33 +25,183 @@ const SIDEBAR_WIDTH = '16rem';
25
25
  const SIDEBAR_WIDTH_ICON = '44px';
26
26
  const SIDEBAR_KEYBOARD_SHORTCUT = '[';
27
27
 
28
- interface SidebarContextProps {
28
+ type SidebarSide = 'left' | 'right';
29
+
30
+ type SidebarOpenState = Record<SidebarSide, boolean>;
31
+
32
+ type SidebarOpenValue = boolean | Partial<SidebarOpenState>;
33
+
34
+ type SidebarStorageKey = string | Partial<Record<SidebarSide, string>>;
35
+
36
+ interface SidebarContextSideState {
29
37
  state: 'expanded' | 'collapsed';
30
38
  open: boolean;
31
- setOpen: (open: boolean) => void;
39
+ setOpen: (open: boolean | ((open: boolean) => boolean)) => void;
32
40
  openMobile: boolean;
33
- setOpenMobile: (open: boolean) => void;
34
- isMobile: boolean;
41
+ setOpenMobile: (open: boolean | ((open: boolean) => boolean)) => void;
35
42
  toggleSidebar: () => void;
36
43
  }
37
44
 
45
+ interface SidebarContextProps {
46
+ isMobile: boolean;
47
+ sides: Record<SidebarSide, SidebarContextSideState>;
48
+ }
49
+
38
50
  const SidebarContext = React.createContext<SidebarContextProps | null>(null);
51
+ const SidebarSideContext = React.createContext<SidebarSide>('left');
52
+
53
+ const resolveSideValue = (
54
+ value: SidebarOpenValue | undefined,
55
+ side: SidebarSide,
56
+ fallback: boolean
57
+ ) => {
58
+ if (typeof value === 'boolean') {
59
+ return value;
60
+ }
61
+
62
+ if (value && typeof value === 'object' && typeof value[side] === 'boolean') {
63
+ return value[side] as boolean;
64
+ }
65
+
66
+ return fallback;
67
+ };
68
+
69
+ const resolveControlledOpen = (
70
+ value: SidebarOpenValue | undefined,
71
+ side: SidebarSide
72
+ ) => {
73
+ if (typeof value === 'boolean') {
74
+ return value;
75
+ }
76
+
77
+ if (value && typeof value === 'object' && typeof value[side] === 'boolean') {
78
+ return value[side] as boolean;
79
+ }
80
+
81
+ return undefined;
82
+ };
83
+
84
+ const resolveStorageKey = (
85
+ storageKey: SidebarStorageKey,
86
+ side: SidebarSide
87
+ ) => {
88
+ if (typeof storageKey === 'string') {
89
+ return side === 'left' ? storageKey : `${storageKey}_right`;
90
+ }
91
+
92
+ if (storageKey && typeof storageKey === 'object') {
93
+ return (
94
+ storageKey[side] ??
95
+ (side === 'left' ? 'sidebar_expanded' : 'sidebar_expanded_right')
96
+ );
97
+ }
98
+
99
+ return side === 'left' ? 'sidebar_expanded' : 'sidebar_expanded_right';
100
+ };
101
+
102
+ const useSidebarSideState = ({
103
+ side,
104
+ isMobile,
105
+ defaultOpen,
106
+ openProp,
107
+ onOpenChange,
108
+ storageKey,
109
+ lastToggledSideRef,
110
+ }: {
111
+ side: SidebarSide;
112
+ isMobile: boolean;
113
+ defaultOpen: SidebarOpenValue | undefined;
114
+ openProp: SidebarOpenValue | undefined;
115
+ onOpenChange?: (open: boolean, side?: SidebarSide) => void;
116
+ storageKey: SidebarStorageKey;
117
+ lastToggledSideRef: React.MutableRefObject<SidebarSide>;
118
+ }): SidebarContextSideState => {
119
+ const defaultFallback = typeof defaultOpen === 'boolean' ? defaultOpen : true;
120
+ const initialDefaultOpen = resolveSideValue(
121
+ defaultOpen,
122
+ side,
123
+ defaultFallback
124
+ );
125
+ const controlledOpen = resolveControlledOpen(openProp, side);
126
+ const isControlled = typeof controlledOpen === 'boolean';
127
+
128
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(
129
+ controlledOpen ?? initialDefaultOpen
130
+ );
131
+ const [openMobile, setOpenMobile] = useState(() =>
132
+ isMobile ? false : controlledOpen ?? initialDefaultOpen
133
+ );
134
+
135
+ const open = controlledOpen ?? uncontrolledOpen;
136
+
137
+ useEffect(() => {
138
+ if (isMobile) {
139
+ setOpenMobile(false);
140
+ } else {
141
+ setUncontrolledOpen(controlledOpen ?? initialDefaultOpen);
142
+ }
143
+ }, [isMobile, controlledOpen, initialDefaultOpen]);
144
+
145
+ const setOpen = useCallback(
146
+ (value: boolean | ((value: boolean) => boolean)) => {
147
+ const newOpenState = typeof value === 'function' ? value(open) : value;
148
+ if (newOpenState === open) {
149
+ return;
150
+ }
151
+
152
+ if (!isControlled) {
153
+ setUncontrolledOpen(newOpenState);
154
+ }
155
+
156
+ onOpenChange?.(newOpenState, side);
157
+
158
+ const key = resolveStorageKey(storageKey, side);
159
+ localStorage.setItem(key, `${newOpenState}`);
160
+ },
161
+ [open, isControlled, onOpenChange, side, storageKey]
162
+ );
163
+
164
+ const toggleSidebar = useCallback(() => {
165
+ lastToggledSideRef.current = side;
166
+ isMobile ? setOpenMobile((value) => !value) : setOpen((value) => !value);
167
+ }, [isMobile, setOpen, side, lastToggledSideRef]);
168
+
169
+ const state = open ? 'expanded' : 'collapsed';
170
+
171
+ return useMemo(
172
+ () => ({
173
+ state,
174
+ open,
175
+ setOpen,
176
+ openMobile,
177
+ setOpenMobile,
178
+ toggleSidebar,
179
+ }),
180
+ [state, open, setOpen, openMobile, setOpenMobile, toggleSidebar]
181
+ );
182
+ };
39
183
 
40
- function useSidebar() {
184
+ function useSidebar(sideOverride?: SidebarSide) {
41
185
  const context = React.useContext(SidebarContext);
42
186
  if (!context) {
43
187
  throw new Error('useSidebar must be used within a SidebarProvider.');
44
188
  }
45
- return context;
189
+ const contextSide = React.useContext(SidebarSideContext);
190
+ const side = sideOverride ?? contextSide;
191
+ return {
192
+ ...context.sides[side],
193
+ isMobile: context.isMobile,
194
+ side,
195
+ };
46
196
  }
47
197
 
48
198
  const SidebarProvider = forwardRef<
49
199
  HTMLDivElement,
50
200
  React.ComponentProps<'div'> & {
51
- defaultOpen?: boolean;
52
- open?: boolean;
53
- storageKey?: string;
54
- onOpenChange?: (open: boolean) => void;
201
+ defaultOpen?: SidebarOpenValue;
202
+ open?: SidebarOpenValue;
203
+ storageKey?: SidebarStorageKey;
204
+ onOpenChange?: (open: boolean, side?: SidebarSide) => void;
55
205
  }
56
206
  >(
57
207
  (
@@ -68,72 +218,49 @@ const SidebarProvider = forwardRef<
68
218
  ref
69
219
  ) => {
70
220
  const isMobile = useIsMobile();
71
- const [openMobile, setOpenMobile] = useState(() =>
72
- isMobile ? false : openProp ?? defaultOpen
73
- );
74
-
75
- // Manages sidebar open state with a fallback to internal state when openProp is not provided
76
- const [_open, _setOpen] = useState(openProp ?? defaultOpen);
77
- const open = openProp ?? _open;
78
-
79
- // Update open state when openProp or isMobile changes
80
- useEffect(() => {
81
- if (isMobile) {
82
- setOpenMobile(false); // Always start closed on mobile
83
- } else {
84
- _setOpen(openProp ?? defaultOpen); // Use desktop state
85
- }
86
- }, [isMobile, openProp, defaultOpen]);
87
-
88
- const setOpen = useCallback(
89
- (value: boolean | ((value: boolean) => boolean)) => {
90
- const newOpenState = typeof value === 'function' ? value(open) : value;
91
-
92
- if (newOpenState !== open) {
93
- if (setOpenProp) {
94
- setOpenProp(newOpenState);
95
- } else {
96
- _setOpen(newOpenState);
97
- }
98
-
99
- localStorage.setItem(storageKey, `${newOpenState}`);
100
- }
101
- },
102
- [setOpenProp, open, storageKey]
103
- );
104
-
105
- // Toggle sidebar based on screen type
106
- const toggleSidebar = useCallback(() => {
107
- isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
108
- }, [isMobile, setOpen, setOpenMobile]);
221
+ const lastToggledSideRef = React.useRef<SidebarSide>('left');
222
+ const leftState = useSidebarSideState({
223
+ side: 'left',
224
+ isMobile,
225
+ defaultOpen,
226
+ openProp,
227
+ onOpenChange: setOpenProp,
228
+ storageKey,
229
+ lastToggledSideRef,
230
+ });
231
+ const rightState = useSidebarSideState({
232
+ side: 'right',
233
+ isMobile,
234
+ defaultOpen,
235
+ openProp,
236
+ onOpenChange: setOpenProp,
237
+ storageKey,
238
+ lastToggledSideRef,
239
+ });
109
240
 
110
241
  // Keydown event handler for toggling sidebar
111
242
  useEffect(() => {
112
243
  const handleKeyDown = (event: KeyboardEvent) => {
113
244
  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT) {
114
245
  event.preventDefault();
115
- toggleSidebar();
246
+ const sideToToggle = lastToggledSideRef.current;
247
+ (sideToToggle === 'left' ? leftState : rightState).toggleSidebar();
116
248
  }
117
249
  };
118
250
 
119
251
  window.addEventListener('keydown', handleKeyDown);
120
252
  return () => window.removeEventListener('keydown', handleKeyDown);
121
- }, [toggleSidebar]);
122
-
123
- // Assign state for data attributes
124
- const state = open ? 'expanded' : 'collapsed';
253
+ }, [leftState, rightState]);
125
254
 
126
255
  const contextValue = useMemo<SidebarContextProps>(
127
256
  () => ({
128
- state,
129
- open,
130
- setOpen,
131
257
  isMobile,
132
- openMobile,
133
- setOpenMobile,
134
- toggleSidebar,
258
+ sides: {
259
+ left: leftState,
260
+ right: rightState,
261
+ },
135
262
  }),
136
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
263
+ [isMobile, leftState, rightState]
137
264
  );
138
265
 
139
266
  return (
@@ -167,7 +294,7 @@ SidebarProvider.displayName = 'SidebarProvider';
167
294
  const Sidebar = React.forwardRef<
168
295
  HTMLDivElement,
169
296
  React.ComponentProps<'div'> & {
170
- side?: 'left'; // no right sidebar yet
297
+ side?: 'left' | 'right';
171
298
  collapsible?: 'offcanvas' | 'icon' | 'none';
172
299
  }
173
300
  >(
@@ -175,106 +302,134 @@ const Sidebar = React.forwardRef<
175
302
  { side = 'left', collapsible = 'offcanvas', className, children, ...props },
176
303
  ref
177
304
  ) => {
178
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
305
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar(side);
179
306
 
180
307
  if (isMobile) {
181
308
  return (
182
- <Drawer
183
- isOpen={openMobile}
184
- onDismiss={() => setOpenMobile(false)}
185
- placement={side}
186
- >
187
- <Box data-sidebar="sidebar" data-mobile="true" height="100">
188
- {children}
189
- </Box>
190
- </Drawer>
309
+ <SidebarSideContext.Provider value={side}>
310
+ <Drawer
311
+ isOpen={openMobile}
312
+ onDismiss={() => setOpenMobile(false)}
313
+ placement={side}
314
+ >
315
+ <Box data-sidebar="sidebar" data-mobile="true" height="100">
316
+ {children}
317
+ </Box>
318
+ </Drawer>
319
+ </SidebarSideContext.Provider>
191
320
  );
192
321
  }
193
322
 
194
323
  if (collapsible === 'none') {
195
324
  return (
196
- <div
197
- className={classNames(
198
- 'group display-flex h-100 font-size-xs flex-direction-column background-color-secondary font-color-base',
199
- className
200
- )}
201
- style={{
202
- width: 'var(--sidebar-width)',
203
- }}
204
- ref={ref}
205
- {...props}
206
- >
207
- {children}
208
- </div>
325
+ <SidebarSideContext.Provider value={side}>
326
+ <div
327
+ className={classNames(
328
+ 'group display-flex h-100 font-size-xs flex-direction-column background-color-secondary font-color-base',
329
+ className
330
+ )}
331
+ style={{
332
+ width: 'var(--sidebar-width)',
333
+ }}
334
+ ref={ref}
335
+ {...props}
336
+ >
337
+ {children}
338
+ </div>
339
+ </SidebarSideContext.Provider>
209
340
  );
210
341
  }
211
342
 
212
343
  return (
213
- <Box
214
- ref={ref}
215
- background="primary"
216
- display={{ base: 'none', desktop: 'block' }}
217
- color="base"
218
- fontSize="sm"
219
- position="relative"
220
- data-state={state}
221
- data-collapsible={collapsible}
222
- data-side={side}
223
- className="group"
224
- >
225
- <div
226
- style={{
227
- animationTimingFunction: 'var(--sidebar-transition-timing, linear)',
228
- transitionTimingFunction:
229
- 'var(--sidebar-transition-timing, linear)',
230
- transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
231
- animationDuration: 'var(--sidebar-transition-duration, 200ms)',
232
- transitionProperty: 'width',
233
- width:
234
- state === 'collapsed' && collapsible === 'icon'
235
- ? 'var(--sidebar-width-icon)'
236
- : state === 'collapsed'
237
- ? '0'
238
- : 'var(--sidebar-width)',
239
- height: '100svh',
240
- }}
241
- className={classNames('position-relative', className)}
242
- />
243
- <div
244
- className={classNames(
245
- 'position-absolute display-none display-flex-desktop ',
246
- className
247
- )}
248
- style={{
249
- left:
250
- state === 'expanded' || collapsible === 'icon'
251
- ? '0'
252
- : 'calc(var(--sidebar-width)*-1)',
253
- top: '0',
254
- bottom: '0',
255
- zIndex: 'var(--size-z-index-drawer)',
256
- animationTimingFunction: 'var(--sidebar-transition-timing, linear)',
257
- transitionTimingFunction:
258
- 'var(--sidebar-transition-timing, linear)',
259
- transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
260
- animationDuration: 'var(--sidebar-transition-duration, 200ms)',
261
- transitionProperty: 'left, right, width',
262
- width:
263
- state === 'collapsed' && collapsible === 'icon'
264
- ? 'var(--sidebar-width-icon)'
265
- : 'var(--sidebar-width)',
266
- height: '100svh',
267
- }}
268
- {...props}
344
+ <SidebarSideContext.Provider value={side}>
345
+ <Box
346
+ ref={ref}
347
+ background="primary"
348
+ display={{ base: 'none', desktop: 'block' }}
349
+ color="base"
350
+ fontSize="sm"
351
+ position="relative"
352
+ style={
353
+ side === 'right' && collapsible === 'offcanvas'
354
+ ? { overflowX: 'hidden' }
355
+ : undefined
356
+ }
357
+ data-state={state}
358
+ data-collapsible={collapsible}
359
+ data-side={side}
360
+ className="group"
269
361
  >
270
362
  <div
271
- data-sidebar="sidebar"
272
- className="display-flex h-100 w-100 flex-direction-column background-color-secondary font-color-base"
363
+ style={{
364
+ animationTimingFunction:
365
+ 'var(--sidebar-transition-timing, linear)',
366
+ transitionTimingFunction:
367
+ 'var(--sidebar-transition-timing, linear)',
368
+ transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
369
+ animationDuration: 'var(--sidebar-transition-duration, 200ms)',
370
+ transitionProperty: 'width',
371
+ width:
372
+ state === 'collapsed' && collapsible === 'icon'
373
+ ? 'var(--sidebar-width-icon)'
374
+ : state === 'collapsed'
375
+ ? '0'
376
+ : 'var(--sidebar-width)',
377
+ height: '100svh',
378
+ }}
379
+ className={classNames('position-relative', className)}
380
+ />
381
+ <div
382
+ className={classNames(
383
+ 'position-absolute display-none display-flex-desktop ',
384
+ className
385
+ )}
386
+ style={{
387
+ left:
388
+ side === 'left' &&
389
+ (state === 'expanded' || collapsible === 'icon')
390
+ ? '0'
391
+ : side === 'left'
392
+ ? 'calc(var(--sidebar-width)*-1)'
393
+ : undefined,
394
+ right:
395
+ side === 'right' &&
396
+ (state === 'expanded' || collapsible === 'icon')
397
+ ? '0'
398
+ : side === 'right'
399
+ ? 'calc(var(--sidebar-width)*-1)'
400
+ : undefined,
401
+ top: '0',
402
+ bottom: '0',
403
+ zIndex: 'var(--size-z-index-drawer)',
404
+ animationTimingFunction:
405
+ 'var(--sidebar-transition-timing, linear)',
406
+ transitionTimingFunction:
407
+ 'var(--sidebar-transition-timing, linear)',
408
+ transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
409
+ animationDuration: 'var(--sidebar-transition-duration, 200ms)',
410
+ transitionProperty: 'left, right, width',
411
+ width:
412
+ state === 'collapsed' && collapsible === 'icon'
413
+ ? 'var(--sidebar-width-icon)'
414
+ : 'var(--sidebar-width)',
415
+ height: '100svh',
416
+ }}
417
+ {...props}
273
418
  >
274
- {children}
419
+ <div
420
+ data-sidebar="sidebar"
421
+ className={classNames(
422
+ 'display-flex h-100 w-100 flex-direction-column background-color-secondary font-color-base',
423
+ {
424
+ 'p-right-lg-desktop': side === 'right',
425
+ }
426
+ )}
427
+ >
428
+ {children}
429
+ </div>
275
430
  </div>
276
- </div>
277
- </Box>
431
+ </Box>
432
+ </SidebarSideContext.Provider>
278
433
  );
279
434
  }
280
435
  );
@@ -282,9 +437,12 @@ Sidebar.displayName = 'Sidebar';
282
437
 
283
438
  const SidebarTrigger = React.forwardRef<
284
439
  React.ElementRef<typeof Button>,
285
- React.ComponentProps<typeof Button>
286
- >(({ className, onClick, ...props }, ref) => {
287
- const { toggleSidebar } = useSidebar();
440
+ React.ComponentProps<typeof Button> & {
441
+ side?: SidebarSide;
442
+ iconName?: IconName;
443
+ }
444
+ >(({ className, onClick, side, iconName = 'dock-left', ...props }, ref) => {
445
+ const { toggleSidebar, side: contextSide } = useSidebar(side);
288
446
 
289
447
  return (
290
448
  <Button
@@ -292,13 +450,19 @@ const SidebarTrigger = React.forwardRef<
292
450
  data-sidebar="trigger"
293
451
  variant="tertiary"
294
452
  size="sm"
295
- iconPrefix="dock-left"
296
- className={classNames('m-left-sm m-left-0-tablet', className)}
453
+ iconPrefix={iconName}
454
+ className={classNames(
455
+ {
456
+ 'm-left-sm m-left-0-tablet': contextSide === 'left',
457
+ 'm-right-sm m-right-0-tablet': contextSide === 'right',
458
+ },
459
+ className
460
+ )}
297
461
  onClick={(event) => {
298
462
  onClick?.(event);
299
463
  toggleSidebar();
300
464
  }}
301
- aria-label="toggle sidebar"
465
+ aria-label={`Toggle ${contextSide} sidebar`}
302
466
  {...props}
303
467
  />
304
468
  );
@@ -429,7 +593,7 @@ const SidebarMenuButton = React.forwardRef<
429
593
  ref
430
594
  ) => {
431
595
  const Comp = asChild ? Slot : 'button';
432
- const { isMobile, state } = useSidebar();
596
+ const { isMobile, state, side } = useSidebar();
433
597
 
434
598
  const button = (
435
599
  <Comp
@@ -464,7 +628,7 @@ const SidebarMenuButton = React.forwardRef<
464
628
  <Tooltip>
465
629
  <TooltipTrigger asChild>{button}</TooltipTrigger>
466
630
  <TooltipContent
467
- side="right"
631
+ side={side === 'right' ? 'left' : 'right'}
468
632
  align="center"
469
633
  hidden={state !== 'collapsed' || isMobile}
470
634
  {...tooltip}
@@ -594,9 +758,15 @@ const SidebarRail = React.forwardRef<
594
758
  HTMLButtonElement,
595
759
  React.ComponentProps<'button'>
596
760
  >(({ className, ...props }, ref) => {
597
- const { open, toggleSidebar } = useSidebar();
761
+ const { open, toggleSidebar, side } = useSidebar();
598
762
 
599
- const caretIcon = open ? 'caret-sm-left' : 'caret-sm-right';
763
+ const caretIcon = open
764
+ ? side === 'right'
765
+ ? 'caret-sm-right'
766
+ : 'caret-sm-left'
767
+ : side === 'right'
768
+ ? 'caret-sm-left'
769
+ : 'caret-sm-right';
600
770
 
601
771
  return (
602
772
  <button
@@ -610,15 +780,18 @@ const SidebarRail = React.forwardRef<
610
780
  styles.rail,
611
781
  'hover-show-child background-color-transparent display-flex p-top-5xl p-left-xl p-right-0 justify-content-center position-absolute',
612
782
  {
613
- 'cursor-w-resize': open,
614
- 'cursor-e-resize': !open,
783
+ 'cursor-w-resize':
784
+ (open && side === 'left') || (!open && side === 'right'),
785
+ 'cursor-e-resize':
786
+ (!open && side === 'left') || (open && side === 'right'),
615
787
  },
616
788
  className
617
789
  )}
618
790
  style={{
619
791
  top: '20px',
620
792
  bottom: '20px',
621
- right: '-14px',
793
+ right: side === 'left' ? '-14px' : undefined,
794
+ left: side === 'right' ? '-14px' : undefined,
622
795
  width: '10px',
623
796
  }}
624
797
  {...props}
@@ -638,8 +811,10 @@ const SidebarRail = React.forwardRef<
638
811
  className={classNames(
639
812
  'hover-child',
640
813
  {
641
- 'cursor-w-resize': open,
642
- 'cursor-e-resize': !open,
814
+ 'cursor-w-resize':
815
+ (open && side === 'left') || (!open && side === 'right'),
816
+ 'cursor-e-resize':
817
+ (!open && side === 'left') || (open && side === 'right'),
643
818
  },
644
819
  className
645
820
  )}