@hyphen/hyphen-components 9.0.0 → 9.1.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/dist/css/index.css +17 -0
- package/dist/css/index.css.map +1 -1
- package/dist/css/utilities.css +1 -1
- package/dist/css/utilities.css.map +1 -1
- package/dist/hyphen-components.cjs.development.js +355 -143
- package/dist/hyphen-components.cjs.development.js.map +1 -1
- package/dist/hyphen-components.cjs.production.min.js +2 -2
- package/dist/hyphen-components.cjs.production.min.js.map +1 -1
- package/dist/hyphen-components.esm.js +346 -134
- package/dist/hyphen-components.esm.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/package.json +4 -3
- package/src/components/Sidebar/Sidebar.mdx +18 -0
- package/src/components/Sidebar/Sidebar.module.scss +18 -0
- package/src/components/Sidebar/Sidebar.react17.test.tsx +35 -0
- package/src/components/Sidebar/Sidebar.resize.test.tsx +368 -0
- package/src/components/Sidebar/Sidebar.stories.tsx +36 -0
- package/src/components/Sidebar/Sidebar.test.tsx +46 -0
- package/src/components/Sidebar/Sidebar.tsx +242 -150
- package/src/components/Sidebar/useSidebarResize.ts +214 -0
|
@@ -5,6 +5,7 @@ import React, {
|
|
|
5
5
|
useEffect,
|
|
6
6
|
forwardRef,
|
|
7
7
|
} from 'react';
|
|
8
|
+
import { useId } from '@radix-ui/react-id';
|
|
8
9
|
import { Slot } from '@radix-ui/react-slot';
|
|
9
10
|
import classNames from 'classnames';
|
|
10
11
|
import { Button } from '../Button/Button';
|
|
@@ -14,6 +15,11 @@ import { Box } from '../Box/Box';
|
|
|
14
15
|
import { IconName } from 'src/types';
|
|
15
16
|
import { Icon } from '../Icon/Icon';
|
|
16
17
|
import styles from './Sidebar.module.scss';
|
|
18
|
+
import {
|
|
19
|
+
SidebarResizeContext,
|
|
20
|
+
SidebarResizeProps,
|
|
21
|
+
useSidebarResize,
|
|
22
|
+
} from './useSidebarResize';
|
|
17
23
|
import {
|
|
18
24
|
Tooltip,
|
|
19
25
|
TooltipContent,
|
|
@@ -326,6 +332,14 @@ const SidebarProvider = forwardRef<
|
|
|
326
332
|
) {
|
|
327
333
|
return;
|
|
328
334
|
}
|
|
335
|
+
// Leave Cmd/Ctrl combinations (e.g. Cmd+[ for back) to the browser.
|
|
336
|
+
// AltGraph also sets ctrlKey on Windows and types [ or ] on some layouts.
|
|
337
|
+
if (
|
|
338
|
+
event.metaKey ||
|
|
339
|
+
(event.ctrlKey && !event.getModifierState('AltGraph'))
|
|
340
|
+
) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
329
343
|
const shortcutSide =
|
|
330
344
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
331
345
|
? 'left'
|
|
@@ -369,6 +383,7 @@ const SidebarProvider = forwardRef<
|
|
|
369
383
|
className
|
|
370
384
|
)}
|
|
371
385
|
ref={ref}
|
|
386
|
+
data-sidebar-provider=""
|
|
372
387
|
{...props}
|
|
373
388
|
>
|
|
374
389
|
{children}
|
|
@@ -384,38 +399,76 @@ SidebarProvider.displayName = 'SidebarProvider';
|
|
|
384
399
|
|
|
385
400
|
const Sidebar = React.forwardRef<
|
|
386
401
|
HTMLDivElement,
|
|
387
|
-
React.ComponentProps<'div'> &
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
React.ComponentProps<'div'> &
|
|
403
|
+
SidebarResizeProps & {
|
|
404
|
+
/**
|
|
405
|
+
* Which edge of the viewport the sidebar is attached to.
|
|
406
|
+
*/
|
|
407
|
+
side?: 'left' | 'right';
|
|
408
|
+
/**
|
|
409
|
+
* Collapse behavior when the sidebar is toggled closed: slide fully out
|
|
410
|
+
* of view (`offcanvas`), collapse to a narrow icon strip (`icon`), or
|
|
411
|
+
* stay at full width (`none`).
|
|
412
|
+
*/
|
|
413
|
+
collapsible?: 'offcanvas' | 'icon' | 'none';
|
|
414
|
+
}
|
|
399
415
|
>(
|
|
400
416
|
(
|
|
401
|
-
{
|
|
417
|
+
{
|
|
418
|
+
side = 'left',
|
|
419
|
+
collapsible = 'offcanvas',
|
|
420
|
+
className,
|
|
421
|
+
children,
|
|
422
|
+
resizable,
|
|
423
|
+
defaultWidth,
|
|
424
|
+
minWidth,
|
|
425
|
+
maxWidth,
|
|
426
|
+
widthStorageKey,
|
|
427
|
+
...props
|
|
428
|
+
},
|
|
402
429
|
ref
|
|
403
430
|
) => {
|
|
404
431
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(side);
|
|
405
|
-
const
|
|
432
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
433
|
+
const setRootRef = React.useCallback(
|
|
434
|
+
(node: HTMLDivElement | null) => {
|
|
435
|
+
rootRef.current = node;
|
|
436
|
+
if (typeof ref === 'function') ref(node);
|
|
437
|
+
else if (ref) ref.current = node;
|
|
438
|
+
},
|
|
439
|
+
[ref]
|
|
440
|
+
);
|
|
441
|
+
const resize = useSidebarResize({
|
|
442
|
+
resizable,
|
|
443
|
+
defaultWidth,
|
|
444
|
+
minWidth,
|
|
445
|
+
maxWidth,
|
|
446
|
+
widthStorageKey,
|
|
447
|
+
side,
|
|
448
|
+
isMobile,
|
|
449
|
+
expanded: state === 'expanded' || collapsible === 'none',
|
|
450
|
+
rootRef,
|
|
451
|
+
});
|
|
452
|
+
const sidebarWidth = resizable
|
|
453
|
+
? `${resize.width}px`
|
|
454
|
+
: side === 'right'
|
|
455
|
+
? SIDEBAR_RIGHT_WIDTH
|
|
456
|
+
: SIDEBAR_WIDTH;
|
|
406
457
|
|
|
407
458
|
if (isMobile) {
|
|
408
459
|
return (
|
|
409
460
|
<SidebarSideContext.Provider value={side}>
|
|
410
|
-
<
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
461
|
+
<SidebarResizeContext.Provider value={resize}>
|
|
462
|
+
<Drawer
|
|
463
|
+
isOpen={openMobile}
|
|
464
|
+
onDismiss={() => setOpenMobile(false)}
|
|
465
|
+
placement={side}
|
|
466
|
+
>
|
|
467
|
+
<Box data-sidebar="sidebar" data-mobile="true" height="100">
|
|
468
|
+
{children}
|
|
469
|
+
</Box>
|
|
470
|
+
</Drawer>
|
|
471
|
+
</SidebarResizeContext.Provider>
|
|
419
472
|
</SidebarSideContext.Provider>
|
|
420
473
|
);
|
|
421
474
|
}
|
|
@@ -423,97 +476,106 @@ const Sidebar = React.forwardRef<
|
|
|
423
476
|
if (collapsible === 'none') {
|
|
424
477
|
return (
|
|
425
478
|
<SidebarSideContext.Provider value={side}>
|
|
426
|
-
<
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
{
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
479
|
+
<SidebarResizeContext.Provider value={resize}>
|
|
480
|
+
<div
|
|
481
|
+
className={classNames(
|
|
482
|
+
'group display-flex h-100 font-size-xs flex-direction-column background-color-secondary font-color-base',
|
|
483
|
+
className
|
|
484
|
+
)}
|
|
485
|
+
style={
|
|
486
|
+
{
|
|
487
|
+
'--sidebar-width': sidebarWidth,
|
|
488
|
+
width: 'var(--sidebar-width)',
|
|
489
|
+
...(resizable ? { position: 'relative', flexShrink: 0 } : {}),
|
|
490
|
+
} as React.CSSProperties
|
|
491
|
+
}
|
|
492
|
+
ref={setRootRef}
|
|
493
|
+
{...props}
|
|
494
|
+
>
|
|
495
|
+
{children}
|
|
496
|
+
</div>
|
|
497
|
+
</SidebarResizeContext.Provider>
|
|
442
498
|
</SidebarSideContext.Provider>
|
|
443
499
|
);
|
|
444
500
|
}
|
|
445
501
|
|
|
446
502
|
return (
|
|
447
503
|
<SidebarSideContext.Provider value={side}>
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
{
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
<div
|
|
466
|
-
style={{
|
|
467
|
-
animationTimingFunction:
|
|
468
|
-
'var(--sidebar-transition-timing, linear)',
|
|
469
|
-
transitionTimingFunction:
|
|
470
|
-
'var(--sidebar-transition-timing, linear)',
|
|
471
|
-
transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
472
|
-
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
473
|
-
transitionProperty: 'width',
|
|
474
|
-
width: getSidebarWidth(state, collapsible),
|
|
475
|
-
height: '100svh',
|
|
476
|
-
}}
|
|
477
|
-
className={classNames('position-relative', className)}
|
|
478
|
-
/>
|
|
479
|
-
<div
|
|
480
|
-
className={classNames(
|
|
481
|
-
'position-absolute display-none display-flex-desktop ',
|
|
482
|
-
className
|
|
483
|
-
)}
|
|
484
|
-
style={{
|
|
485
|
-
...getSidebarOffsetStyles(side, state, collapsible),
|
|
486
|
-
top: '0',
|
|
487
|
-
bottom: '0',
|
|
488
|
-
zIndex: 'var(--size-z-index-drawer)',
|
|
489
|
-
animationTimingFunction:
|
|
490
|
-
'var(--sidebar-transition-timing, linear)',
|
|
491
|
-
transitionTimingFunction:
|
|
492
|
-
'var(--sidebar-transition-timing, linear)',
|
|
493
|
-
transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
494
|
-
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
495
|
-
transitionProperty: 'left, right, width',
|
|
496
|
-
width:
|
|
497
|
-
state === 'collapsed' && collapsible === 'icon'
|
|
498
|
-
? 'var(--sidebar-width-icon)'
|
|
499
|
-
: 'var(--sidebar-width)',
|
|
500
|
-
height: '100svh',
|
|
501
|
-
}}
|
|
502
|
-
{...props}
|
|
504
|
+
<SidebarResizeContext.Provider value={resize}>
|
|
505
|
+
<Box
|
|
506
|
+
ref={setRootRef}
|
|
507
|
+
background="primary"
|
|
508
|
+
display={{ base: 'none', desktop: 'block' }}
|
|
509
|
+
color="base"
|
|
510
|
+
fontSize="sm"
|
|
511
|
+
position="relative"
|
|
512
|
+
style={
|
|
513
|
+
{
|
|
514
|
+
'--sidebar-width': sidebarWidth,
|
|
515
|
+
} as React.CSSProperties
|
|
516
|
+
}
|
|
517
|
+
data-state={state}
|
|
518
|
+
data-collapsible={collapsible}
|
|
519
|
+
data-side={side}
|
|
520
|
+
className="group"
|
|
503
521
|
>
|
|
504
522
|
<div
|
|
505
|
-
|
|
523
|
+
style={{
|
|
524
|
+
animationTimingFunction:
|
|
525
|
+
'var(--sidebar-transition-timing, linear)',
|
|
526
|
+
transitionTimingFunction:
|
|
527
|
+
'var(--sidebar-transition-timing, linear)',
|
|
528
|
+
transitionDuration: resize.animate
|
|
529
|
+
? 'var(--sidebar-transition-duration, 200ms)'
|
|
530
|
+
: '0ms',
|
|
531
|
+
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
532
|
+
transitionProperty: 'width',
|
|
533
|
+
width: getSidebarWidth(state, collapsible),
|
|
534
|
+
height: '100svh',
|
|
535
|
+
}}
|
|
536
|
+
className={classNames('position-relative', className)}
|
|
537
|
+
/>
|
|
538
|
+
<div
|
|
506
539
|
className={classNames(
|
|
507
|
-
'
|
|
508
|
-
|
|
509
|
-
'p-right-lg-desktop': side === 'right',
|
|
510
|
-
}
|
|
540
|
+
'position-absolute display-none display-flex-desktop ',
|
|
541
|
+
className
|
|
511
542
|
)}
|
|
543
|
+
style={{
|
|
544
|
+
...getSidebarOffsetStyles(side, state, collapsible),
|
|
545
|
+
top: '0',
|
|
546
|
+
bottom: '0',
|
|
547
|
+
zIndex: 'var(--size-z-index-drawer)',
|
|
548
|
+
animationTimingFunction:
|
|
549
|
+
'var(--sidebar-transition-timing, linear)',
|
|
550
|
+
transitionTimingFunction:
|
|
551
|
+
'var(--sidebar-transition-timing, linear)',
|
|
552
|
+
transitionDuration: resize.animate
|
|
553
|
+
? 'var(--sidebar-transition-duration, 200ms)'
|
|
554
|
+
: '0ms',
|
|
555
|
+
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
556
|
+
transitionProperty: 'left, right, width',
|
|
557
|
+
width:
|
|
558
|
+
state === 'collapsed' && collapsible === 'icon'
|
|
559
|
+
? 'var(--sidebar-width-icon)'
|
|
560
|
+
: 'var(--sidebar-width)',
|
|
561
|
+
height: '100svh',
|
|
562
|
+
}}
|
|
563
|
+
{...props}
|
|
512
564
|
>
|
|
513
|
-
|
|
565
|
+
<div
|
|
566
|
+
data-sidebar="sidebar"
|
|
567
|
+
className={classNames(
|
|
568
|
+
'display-flex h-100 w-100 flex-direction-column background-color-secondary font-color-base',
|
|
569
|
+
{
|
|
570
|
+
'p-right-lg-desktop': side === 'right',
|
|
571
|
+
}
|
|
572
|
+
)}
|
|
573
|
+
>
|
|
574
|
+
{children}
|
|
575
|
+
</div>
|
|
514
576
|
</div>
|
|
515
|
-
</
|
|
516
|
-
</
|
|
577
|
+
</Box>
|
|
578
|
+
</SidebarResizeContext.Provider>
|
|
517
579
|
</SidebarSideContext.Provider>
|
|
518
580
|
);
|
|
519
581
|
}
|
|
@@ -860,6 +922,9 @@ const SidebarRail = React.forwardRef<
|
|
|
860
922
|
React.ComponentProps<'button'>
|
|
861
923
|
>(({ className, ...props }, ref) => {
|
|
862
924
|
const { open, toggleSidebar, side } = useSidebar();
|
|
925
|
+
const resize = React.useContext(SidebarResizeContext);
|
|
926
|
+
// React.useId requires React 18; Radix's useId also supports 16.8 and 17.
|
|
927
|
+
const descriptionId = useId();
|
|
863
928
|
const shortcutLabel =
|
|
864
929
|
side === 'left'
|
|
865
930
|
? SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
@@ -870,64 +935,91 @@ const SidebarRail = React.forwardRef<
|
|
|
870
935
|
? 'caret-sm-right'
|
|
871
936
|
: 'caret-sm-left'
|
|
872
937
|
: side === 'right'
|
|
873
|
-
|
|
874
|
-
|
|
938
|
+
? 'caret-sm-left'
|
|
939
|
+
: 'caret-sm-right';
|
|
875
940
|
|
|
876
941
|
return (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
{
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
top: '20px',
|
|
897
|
-
bottom: '20px',
|
|
898
|
-
right: side === 'left' ? '-14px' : undefined,
|
|
899
|
-
left: side === 'right' ? '-18px' : undefined,
|
|
900
|
-
width: '10px',
|
|
901
|
-
}}
|
|
902
|
-
type="button"
|
|
903
|
-
{...props}
|
|
904
|
-
>
|
|
905
|
-
<Box
|
|
906
|
-
radius="xl"
|
|
907
|
-
background="primary"
|
|
908
|
-
color="secondary"
|
|
909
|
-
borderWidth="sm"
|
|
910
|
-
padding="xs"
|
|
911
|
-
margin="0"
|
|
912
|
-
shadow="xs"
|
|
913
|
-
width="3xl"
|
|
914
|
-
height="3xl"
|
|
915
|
-
alignItems="center"
|
|
916
|
-
justifyContent="center"
|
|
942
|
+
<>
|
|
943
|
+
<button
|
|
944
|
+
ref={ref}
|
|
945
|
+
data-sidebar="rail"
|
|
946
|
+
data-resizable={resize?.enabled || undefined}
|
|
947
|
+
aria-label={
|
|
948
|
+
resize?.enabled
|
|
949
|
+
? `Resize or toggle ${side} sidebar`
|
|
950
|
+
: 'Toggle Sidebar'
|
|
951
|
+
}
|
|
952
|
+
aria-describedby={resize?.enabled ? descriptionId : undefined}
|
|
953
|
+
tabIndex={resize?.enabled ? 0 : -1}
|
|
954
|
+
{...(resize?.enabled ? resize.railProps : {})}
|
|
955
|
+
onClick={toggleSidebar}
|
|
956
|
+
title={
|
|
957
|
+
resize?.enabled
|
|
958
|
+
? `Drag to resize; click to toggle (${shortcutLabel})`
|
|
959
|
+
: `Toggle Sidebar ${shortcutLabel}`
|
|
960
|
+
}
|
|
917
961
|
className={classNames(
|
|
918
|
-
|
|
962
|
+
styles.rail,
|
|
963
|
+
'hover-show-child background-color-transparent display-flex p-top-5xl p-left-xl p-right-0 justify-content-center position-absolute',
|
|
919
964
|
{
|
|
920
965
|
'cursor-w-resize':
|
|
921
|
-
|
|
966
|
+
!resize?.enabled &&
|
|
967
|
+
((open && side === 'left') || (!open && side === 'right')),
|
|
922
968
|
'cursor-e-resize':
|
|
923
|
-
|
|
969
|
+
!resize?.enabled &&
|
|
970
|
+
((!open && side === 'left') || (open && side === 'right')),
|
|
924
971
|
},
|
|
925
972
|
className
|
|
926
973
|
)}
|
|
974
|
+
style={{
|
|
975
|
+
top: '20px',
|
|
976
|
+
bottom: '20px',
|
|
977
|
+
right: side === 'left' ? '-14px' : undefined,
|
|
978
|
+
left: side === 'right' ? '-18px' : undefined,
|
|
979
|
+
width: '10px',
|
|
980
|
+
...(resize?.enabled
|
|
981
|
+
? { cursor: 'col-resize', touchAction: 'none' }
|
|
982
|
+
: {}),
|
|
983
|
+
}}
|
|
984
|
+
type="button"
|
|
985
|
+
{...props}
|
|
927
986
|
>
|
|
928
|
-
<
|
|
929
|
-
|
|
930
|
-
|
|
987
|
+
<Box
|
|
988
|
+
radius="xl"
|
|
989
|
+
background="primary"
|
|
990
|
+
color="secondary"
|
|
991
|
+
borderWidth="sm"
|
|
992
|
+
padding="xs"
|
|
993
|
+
margin="0"
|
|
994
|
+
shadow="xs"
|
|
995
|
+
width="3xl"
|
|
996
|
+
height="3xl"
|
|
997
|
+
alignItems="center"
|
|
998
|
+
justifyContent="center"
|
|
999
|
+
className={classNames(
|
|
1000
|
+
'hover-child',
|
|
1001
|
+
{
|
|
1002
|
+
'cursor-w-resize':
|
|
1003
|
+
!resize?.enabled &&
|
|
1004
|
+
((open && side === 'left') || (!open && side === 'right')),
|
|
1005
|
+
'cursor-e-resize':
|
|
1006
|
+
!resize?.enabled &&
|
|
1007
|
+
((!open && side === 'left') || (open && side === 'right')),
|
|
1008
|
+
},
|
|
1009
|
+
className
|
|
1010
|
+
)}
|
|
1011
|
+
>
|
|
1012
|
+
<Icon name={caretIcon} />
|
|
1013
|
+
</Box>
|
|
1014
|
+
</button>
|
|
1015
|
+
{resize?.enabled && (
|
|
1016
|
+
<span id={descriptionId} className={styles.resizeDescription}>
|
|
1017
|
+
Width {Math.round(resize.width)} pixels. Use left and right arrows to
|
|
1018
|
+
resize, Home for minimum, End for maximum, and Enter or Space to
|
|
1019
|
+
toggle.
|
|
1020
|
+
</span>
|
|
1021
|
+
)}
|
|
1022
|
+
</>
|
|
931
1023
|
);
|
|
932
1024
|
});
|
|
933
1025
|
SidebarRail.displayName = 'SidebarRail';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect';
|
|
3
|
+
|
|
4
|
+
export interface SidebarResizeProps {
|
|
5
|
+
/** Enable desktop resizing with SidebarRail. Disabled by default. */
|
|
6
|
+
resizable?: boolean;
|
|
7
|
+
/** Initial expanded width in pixels. Defaults to 256 left / 384 right. */
|
|
8
|
+
defaultWidth?: number;
|
|
9
|
+
/** Minimum expanded width in pixels. Defaults to 256. */
|
|
10
|
+
minWidth?: number;
|
|
11
|
+
/** Maximum expanded width in pixels, also capped at 2/3 of the provider. */
|
|
12
|
+
maxWidth?: number;
|
|
13
|
+
/** Optional localStorage key for the preferred expanded width. */
|
|
14
|
+
widthStorageKey?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSidebarResize({
|
|
18
|
+
resizable = false,
|
|
19
|
+
defaultWidth,
|
|
20
|
+
minWidth = 256,
|
|
21
|
+
maxWidth = 960,
|
|
22
|
+
widthStorageKey,
|
|
23
|
+
side,
|
|
24
|
+
isMobile,
|
|
25
|
+
expanded,
|
|
26
|
+
rootRef,
|
|
27
|
+
}: SidebarResizeProps & {
|
|
28
|
+
side: 'left' | 'right';
|
|
29
|
+
isMobile: boolean;
|
|
30
|
+
expanded: boolean;
|
|
31
|
+
rootRef: React.RefObject<HTMLDivElement>;
|
|
32
|
+
}) {
|
|
33
|
+
const initialWidth = defaultWidth ?? (side === 'left' ? 256 : 384);
|
|
34
|
+
const [preferredWidth, setPreferredWidth] = useState(initialWidth);
|
|
35
|
+
const [containerWidth, setContainerWidth] = useState<number>(Infinity);
|
|
36
|
+
const [dragging, setDragging] = useState(false);
|
|
37
|
+
const gesture = useRef<{
|
|
38
|
+
id: number;
|
|
39
|
+
x: number;
|
|
40
|
+
width: number;
|
|
41
|
+
preferred: number;
|
|
42
|
+
moved: boolean;
|
|
43
|
+
} | null>(null);
|
|
44
|
+
const suppressClick = useRef(false);
|
|
45
|
+
const currentWidth = useRef(preferredWidth);
|
|
46
|
+
const enabled = resizable && !isMobile && expanded;
|
|
47
|
+
const maximum = Math.max(0, Math.min(maxWidth, containerWidth * (2 / 3)));
|
|
48
|
+
const minimum = Math.min(Math.max(0, minWidth), maximum);
|
|
49
|
+
const clamp = (value: number) => Math.max(minimum, Math.min(maximum, value));
|
|
50
|
+
const width = clamp(preferredWidth);
|
|
51
|
+
const [settledWidth, setSettledWidth] = useState(width);
|
|
52
|
+
|
|
53
|
+
// Restore and measure before paint so the first frame uses the saved width.
|
|
54
|
+
useIsomorphicLayoutEffect(() => {
|
|
55
|
+
if (!resizable) return;
|
|
56
|
+
let savedWidth = initialWidth;
|
|
57
|
+
try {
|
|
58
|
+
const saved = widthStorageKey && localStorage.getItem(widthStorageKey);
|
|
59
|
+
if (saved && Number.isFinite(Number(saved)) && Number(saved) > 0) {
|
|
60
|
+
savedWidth = Number(saved);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Resizing still works when browser storage is unavailable.
|
|
64
|
+
}
|
|
65
|
+
setPreferredWidth(savedWidth);
|
|
66
|
+
}, [resizable, initialWidth, widthStorageKey]);
|
|
67
|
+
|
|
68
|
+
useIsomorphicLayoutEffect(() => {
|
|
69
|
+
if (!resizable || isMobile) return;
|
|
70
|
+
const container = rootRef.current?.closest('[data-sidebar-provider]');
|
|
71
|
+
if (!container) return;
|
|
72
|
+
const measure = () =>
|
|
73
|
+
setContainerWidth(container.getBoundingClientRect().width);
|
|
74
|
+
measure();
|
|
75
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
76
|
+
window.addEventListener('resize', measure);
|
|
77
|
+
return () => window.removeEventListener('resize', measure);
|
|
78
|
+
}
|
|
79
|
+
const observer = new ResizeObserver(measure);
|
|
80
|
+
observer.observe(container);
|
|
81
|
+
return () => observer.disconnect();
|
|
82
|
+
}, [resizable, isMobile, rootRef]);
|
|
83
|
+
|
|
84
|
+
// Only expanding and collapsing animate. A width change (restore, container
|
|
85
|
+
// cap, keyboard, drag release) is committed with transitions off; flushing
|
|
86
|
+
// styles at that width lets the next render turn them back on without the
|
|
87
|
+
// browser animating from the old width.
|
|
88
|
+
useIsomorphicLayoutEffect(() => {
|
|
89
|
+
if (dragging || width === settledWidth) return;
|
|
90
|
+
rootRef.current?.getBoundingClientRect();
|
|
91
|
+
setSettledWidth(width);
|
|
92
|
+
}, [dragging, width, settledWidth, rootRef]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!enabled) {
|
|
96
|
+
gesture.current = null;
|
|
97
|
+
setDragging(false);
|
|
98
|
+
}
|
|
99
|
+
}, [enabled]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!dragging) return;
|
|
103
|
+
const { cursor, userSelect } = document.body.style;
|
|
104
|
+
document.body.style.cursor = 'col-resize';
|
|
105
|
+
document.body.style.userSelect = 'none';
|
|
106
|
+
return () => {
|
|
107
|
+
document.body.style.cursor = cursor;
|
|
108
|
+
document.body.style.userSelect = userSelect;
|
|
109
|
+
};
|
|
110
|
+
}, [dragging]);
|
|
111
|
+
|
|
112
|
+
const update = (next: number) => {
|
|
113
|
+
currentWidth.current = clamp(next);
|
|
114
|
+
setPreferredWidth(currentWidth.current);
|
|
115
|
+
};
|
|
116
|
+
const persist = () => {
|
|
117
|
+
if (!widthStorageKey) return;
|
|
118
|
+
try {
|
|
119
|
+
localStorage.setItem(widthStorageKey, String(currentWidth.current));
|
|
120
|
+
} catch {
|
|
121
|
+
// A storage failure must not interrupt the interaction.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const cancel = () => {
|
|
125
|
+
if (!gesture.current) return;
|
|
126
|
+
setPreferredWidth(gesture.current.preferred);
|
|
127
|
+
gesture.current = null;
|
|
128
|
+
setDragging(false);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const railProps: React.ComponentProps<'button'> = {
|
|
132
|
+
onPointerDown: (event) => {
|
|
133
|
+
suppressClick.current = false;
|
|
134
|
+
if (!enabled || event.button !== 0 || gesture.current) return;
|
|
135
|
+
gesture.current = {
|
|
136
|
+
id: event.pointerId,
|
|
137
|
+
x: event.clientX,
|
|
138
|
+
width,
|
|
139
|
+
preferred: preferredWidth,
|
|
140
|
+
moved: false,
|
|
141
|
+
};
|
|
142
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
143
|
+
},
|
|
144
|
+
onPointerMove: (event) => {
|
|
145
|
+
const start = gesture.current;
|
|
146
|
+
if (!start || start.id !== event.pointerId) return;
|
|
147
|
+
const delta = event.clientX - start.x;
|
|
148
|
+
if (!start.moved && Math.abs(delta) < 4) return;
|
|
149
|
+
start.moved = true;
|
|
150
|
+
suppressClick.current = true;
|
|
151
|
+
setDragging(true);
|
|
152
|
+
update(start.width + (side === 'left' ? delta : -delta));
|
|
153
|
+
},
|
|
154
|
+
onPointerUp: (event) => {
|
|
155
|
+
const start = gesture.current;
|
|
156
|
+
if (!start || start.id !== event.pointerId) return;
|
|
157
|
+
if (start.moved) {
|
|
158
|
+
update(
|
|
159
|
+
start.width + (side === 'left' ? 1 : -1) * (event.clientX - start.x)
|
|
160
|
+
);
|
|
161
|
+
persist();
|
|
162
|
+
}
|
|
163
|
+
gesture.current = null;
|
|
164
|
+
setDragging(false);
|
|
165
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
166
|
+
},
|
|
167
|
+
onPointerCancel: cancel,
|
|
168
|
+
onLostPointerCapture: cancel,
|
|
169
|
+
onClickCapture: (event) => {
|
|
170
|
+
if (suppressClick.current && event.detail !== 0) {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
suppressClick.current = false;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
onKeyDown: (event) => {
|
|
177
|
+
// Leave modified keys to the browser, e.g. Cmd/Alt+Arrow for history.
|
|
178
|
+
const modified =
|
|
179
|
+
event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
180
|
+
if (!enabled || modified) return;
|
|
181
|
+
let next: number;
|
|
182
|
+
switch (event.key) {
|
|
183
|
+
case 'ArrowLeft':
|
|
184
|
+
next = width + (side === 'left' ? -10 : 10);
|
|
185
|
+
break;
|
|
186
|
+
case 'ArrowRight':
|
|
187
|
+
next = width + (side === 'left' ? 10 : -10);
|
|
188
|
+
break;
|
|
189
|
+
case 'Home':
|
|
190
|
+
next = minimum;
|
|
191
|
+
break;
|
|
192
|
+
case 'End':
|
|
193
|
+
next = maximum;
|
|
194
|
+
break;
|
|
195
|
+
default:
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
update(next);
|
|
200
|
+
persist();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
width,
|
|
206
|
+
animate: !dragging && width === settledWidth,
|
|
207
|
+
enabled,
|
|
208
|
+
railProps,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const SidebarResizeContext = React.createContext<ReturnType<
|
|
213
|
+
typeof useSidebarResize
|
|
214
|
+
> | null>(null);
|