@sovereignfs/ui 0.41.0 → 0.54.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/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Children, isValidElement, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
CSSProperties,
|
|
6
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
7
|
+
PointerEvent as ReactPointerEvent,
|
|
8
|
+
ReactElement,
|
|
9
|
+
ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import styles from './Resizable.module.css';
|
|
12
|
+
|
|
13
|
+
export type ResizableDirection = 'horizontal' | 'vertical';
|
|
14
|
+
|
|
15
|
+
export interface ResizablePanelProps {
|
|
16
|
+
/** Initial size as a percentage of the group's main axis. Defaults to an
|
|
17
|
+
* equal split across all panels in the group. */
|
|
18
|
+
defaultSize?: number;
|
|
19
|
+
/** Minimum size as a percentage. Defaults to 10. */
|
|
20
|
+
minSize?: number;
|
|
21
|
+
/** Maximum size as a percentage. Defaults to 90. */
|
|
22
|
+
maxSize?: number;
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Injected by ResizablePanelGroup — not part of the public API. */
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One pane of a ResizablePanelGroup. Sizing is computed and injected by the
|
|
30
|
+
* group (via defaultSize/minSize/maxSize read off this element's props) —
|
|
31
|
+
* this component only renders the scrollable content box. */
|
|
32
|
+
export function ResizablePanel({ children, className, style }: ResizablePanelProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className={[styles.panel, className].filter(Boolean).join(' ')} style={style}>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResizableHandleProps {
|
|
41
|
+
'aria-label'?: string;
|
|
42
|
+
/** Injected by ResizablePanelGroup — not part of the public API. */
|
|
43
|
+
direction?: ResizableDirection;
|
|
44
|
+
onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
45
|
+
onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void;
|
|
46
|
+
'aria-valuenow'?: number;
|
|
47
|
+
'aria-valuemin'?: number;
|
|
48
|
+
'aria-valuemax'?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The draggable divider between two ResizablePanels. Placed directly
|
|
52
|
+
* between the two ResizablePanel elements it resizes — the group pairs each
|
|
53
|
+
* handle with its preceding and following panel by position.
|
|
54
|
+
*
|
|
55
|
+
* A `div` with `role="separator"` and `tabIndex=0` (the WAI-ARIA "window
|
|
56
|
+
* splitter" pattern), not a `<button>` — `separator` is a non-interactive
|
|
57
|
+
* role, and a native button's implicit role can't be overridden to it. */
|
|
58
|
+
export function ResizableHandle({
|
|
59
|
+
'aria-label': ariaLabel = 'Resize panels',
|
|
60
|
+
direction = 'horizontal',
|
|
61
|
+
onPointerDown,
|
|
62
|
+
onKeyDown,
|
|
63
|
+
'aria-valuenow': valueNow,
|
|
64
|
+
'aria-valuemin': valueMin,
|
|
65
|
+
'aria-valuemax': valueMax,
|
|
66
|
+
}: ResizableHandleProps) {
|
|
67
|
+
return (
|
|
68
|
+
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex -- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable separator is the documented exception to role="separator" being non-interactive (https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/). */
|
|
69
|
+
<div
|
|
70
|
+
className={[styles.handle, direction === 'vertical' ? styles.handleVertical : '']
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(' ')}
|
|
73
|
+
role="separator"
|
|
74
|
+
tabIndex={0}
|
|
75
|
+
aria-orientation={direction === 'horizontal' ? 'vertical' : 'horizontal'}
|
|
76
|
+
aria-label={ariaLabel}
|
|
77
|
+
aria-valuenow={valueNow !== undefined ? Math.round(valueNow) : undefined}
|
|
78
|
+
aria-valuemin={valueMin}
|
|
79
|
+
aria-valuemax={valueMax}
|
|
80
|
+
onPointerDown={onPointerDown}
|
|
81
|
+
onKeyDown={onKeyDown}
|
|
82
|
+
/>
|
|
83
|
+
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ResizablePanelGroupProps {
|
|
88
|
+
direction: ResizableDirection;
|
|
89
|
+
/** Alternating ResizablePanel / ResizableHandle elements — N panels
|
|
90
|
+
* separated by N-1 handles. */
|
|
91
|
+
children: ReactNode;
|
|
92
|
+
className?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface PanelConstraint {
|
|
96
|
+
defaultSize: number;
|
|
97
|
+
minSize: number;
|
|
98
|
+
maxSize: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ResizablePanelGroup — a row or column of resizable panes (editor layouts,
|
|
105
|
+
* multi-pane inspectors). Each handle resizes only its two immediate
|
|
106
|
+
* neighbor panels (drag redistributes size between them, clamped to both
|
|
107
|
+
* panels' own min/max, so their combined size is a fixed invariant for the
|
|
108
|
+
* duration of one drag) — the same handle-local model most resizable-panel
|
|
109
|
+
* implementations use, rather than redistributing across the whole group.
|
|
110
|
+
*
|
|
111
|
+
* Desktop-oriented, like NavigationMenu — a drag handle has no direct touch
|
|
112
|
+
* equivalent; panels render at their default sizes on touch rather than
|
|
113
|
+
* attempting a redesign no consumer has asked for yet. For a two-pane
|
|
114
|
+
* layout that also needs a mobile single-column fallback, use SplitPane
|
|
115
|
+
* instead.
|
|
116
|
+
*
|
|
117
|
+
* `direction="vertical"` needs a definite-height ancestor — percentage
|
|
118
|
+
* flex-basis on a column's children only resolves against one.
|
|
119
|
+
*/
|
|
120
|
+
export function ResizablePanelGroup({ direction, children, className }: ResizablePanelGroupProps) {
|
|
121
|
+
const elements = Children.toArray(children).filter(isValidElement) as ReactElement<
|
|
122
|
+
ResizablePanelProps | ResizableHandleProps
|
|
123
|
+
>[];
|
|
124
|
+
const panelCount = elements.filter((el) => el.type === ResizablePanel).length;
|
|
125
|
+
|
|
126
|
+
const constraints: PanelConstraint[] = elements
|
|
127
|
+
.filter((el) => el.type === ResizablePanel)
|
|
128
|
+
.map((el) => {
|
|
129
|
+
const props = el.props as ResizablePanelProps;
|
|
130
|
+
return {
|
|
131
|
+
defaultSize: props.defaultSize ?? 100 / panelCount,
|
|
132
|
+
minSize: props.minSize ?? 10,
|
|
133
|
+
maxSize: props.maxSize ?? 90,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const [sizes, setSizes] = useState<number[]>(() => constraints.map((c) => c.defaultSize));
|
|
138
|
+
|
|
139
|
+
// A different set of panels rendered (count changed) — re-derive rather
|
|
140
|
+
// than keep stale entries. Legal "adjust state during render" pattern;
|
|
141
|
+
// sizes.length matches constraints.length again on the next render, so
|
|
142
|
+
// this cannot loop.
|
|
143
|
+
if (sizes.length !== constraints.length) {
|
|
144
|
+
setSizes(constraints.map((c) => c.defaultSize));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
148
|
+
// Always-latest refs so the window-level pointer listeners (registered
|
|
149
|
+
// once, see below) never read stale values without needing to be
|
|
150
|
+
// re-registered on every render.
|
|
151
|
+
const constraintsRef = useRef(constraints);
|
|
152
|
+
constraintsRef.current = constraints;
|
|
153
|
+
const directionRef = useRef(direction);
|
|
154
|
+
directionRef.current = direction;
|
|
155
|
+
|
|
156
|
+
const dragState = useRef<{
|
|
157
|
+
handleIndex: number;
|
|
158
|
+
pointerId: number;
|
|
159
|
+
startPos: number;
|
|
160
|
+
startA: number;
|
|
161
|
+
combined: number;
|
|
162
|
+
} | null>(null);
|
|
163
|
+
|
|
164
|
+
function applyResize(handleIndex: number, nextA: number, combined: number) {
|
|
165
|
+
const a = constraintsRef.current[handleIndex];
|
|
166
|
+
const b = constraintsRef.current[handleIndex + 1];
|
|
167
|
+
if (!a || !b) return;
|
|
168
|
+
const clampedA = clamp(
|
|
169
|
+
nextA,
|
|
170
|
+
Math.max(a.minSize, combined - b.maxSize),
|
|
171
|
+
Math.min(a.maxSize, combined - b.minSize),
|
|
172
|
+
);
|
|
173
|
+
setSizes((prev) => {
|
|
174
|
+
const next = [...prev];
|
|
175
|
+
next[handleIndex] = clampedA;
|
|
176
|
+
next[handleIndex + 1] = combined - clampedA;
|
|
177
|
+
return next;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
function handlePointerMove(event: globalThis.PointerEvent) {
|
|
183
|
+
const drag = dragState.current;
|
|
184
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
185
|
+
if (!drag || !rect || event.pointerId !== drag.pointerId) return;
|
|
186
|
+
const size = directionRef.current === 'horizontal' ? rect.width : rect.height;
|
|
187
|
+
if (size === 0) return;
|
|
188
|
+
const pos = directionRef.current === 'horizontal' ? event.clientX : event.clientY;
|
|
189
|
+
const deltaPercent = ((pos - drag.startPos) / size) * 100;
|
|
190
|
+
applyResize(drag.handleIndex, drag.startA + deltaPercent, drag.combined);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stopDragging(event: globalThis.PointerEvent) {
|
|
194
|
+
if (!dragState.current || event.pointerId !== dragState.current.pointerId) return;
|
|
195
|
+
dragState.current = null;
|
|
196
|
+
document.body.style.cursor = '';
|
|
197
|
+
document.body.style.userSelect = '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
201
|
+
window.addEventListener('pointerup', stopDragging);
|
|
202
|
+
window.addEventListener('pointercancel', stopDragging);
|
|
203
|
+
return () => {
|
|
204
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
205
|
+
window.removeEventListener('pointerup', stopDragging);
|
|
206
|
+
window.removeEventListener('pointercancel', stopDragging);
|
|
207
|
+
document.body.style.cursor = '';
|
|
208
|
+
document.body.style.userSelect = '';
|
|
209
|
+
};
|
|
210
|
+
// Registered once — handlePointerMove reads live state via refs instead
|
|
211
|
+
// of closing over props/state that would otherwise need to be deps.
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
function startDragging(handleIndex: number, event: ReactPointerEvent<HTMLDivElement>) {
|
|
215
|
+
const startA = sizes[handleIndex] ?? 0;
|
|
216
|
+
const startB = sizes[handleIndex + 1] ?? 0;
|
|
217
|
+
dragState.current = {
|
|
218
|
+
handleIndex,
|
|
219
|
+
pointerId: event.pointerId,
|
|
220
|
+
startPos: direction === 'horizontal' ? event.clientX : event.clientY,
|
|
221
|
+
startA,
|
|
222
|
+
combined: startA + startB,
|
|
223
|
+
};
|
|
224
|
+
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
|
225
|
+
document.body.style.userSelect = 'none';
|
|
226
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resizeWithKeyboard(handleIndex: number, event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
230
|
+
const step = event.shiftKey ? 10 : 5;
|
|
231
|
+
const forwardKey = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
232
|
+
const backwardKey = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
233
|
+
const sizeA = sizes[handleIndex] ?? 0;
|
|
234
|
+
const sizeB = sizes[handleIndex + 1] ?? 0;
|
|
235
|
+
const combined = sizeA + sizeB;
|
|
236
|
+
if (event.key === forwardKey) {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
applyResize(handleIndex, sizeA + step, combined);
|
|
239
|
+
} else if (event.key === backwardKey) {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
applyResize(handleIndex, sizeA - step, combined);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let panelIndex = -1;
|
|
246
|
+
let handleIndex = -1;
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div
|
|
250
|
+
ref={containerRef}
|
|
251
|
+
className={[styles.group, direction === 'vertical' ? styles.groupVertical : '', className]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(' ')}
|
|
254
|
+
>
|
|
255
|
+
{elements.map((element, key) => {
|
|
256
|
+
if (element.type === ResizablePanel) {
|
|
257
|
+
panelIndex += 1;
|
|
258
|
+
const size = sizes[panelIndex] ?? constraints[panelIndex]?.defaultSize ?? 0;
|
|
259
|
+
return (
|
|
260
|
+
<ResizablePanelClone
|
|
261
|
+
key={key}
|
|
262
|
+
element={element as ReactElement<ResizablePanelProps>}
|
|
263
|
+
flexBasis={size}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (element.type === ResizableHandle) {
|
|
268
|
+
handleIndex += 1;
|
|
269
|
+
const index = handleIndex;
|
|
270
|
+
const constraint = constraints[index];
|
|
271
|
+
return (
|
|
272
|
+
<ResizableHandleClone
|
|
273
|
+
key={key}
|
|
274
|
+
element={element as ReactElement<ResizableHandleProps>}
|
|
275
|
+
direction={direction}
|
|
276
|
+
aria-valuenow={sizes[index]}
|
|
277
|
+
aria-valuemin={constraint?.minSize}
|
|
278
|
+
aria-valuemax={constraint?.maxSize}
|
|
279
|
+
onPointerDown={(e) => startDragging(index, e)}
|
|
280
|
+
onKeyDown={(e) => resizeWithKeyboard(index, e)}
|
|
281
|
+
/>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return element;
|
|
285
|
+
})}
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ResizablePanelClone({
|
|
291
|
+
element,
|
|
292
|
+
flexBasis,
|
|
293
|
+
}: {
|
|
294
|
+
element: ReactElement<ResizablePanelProps>;
|
|
295
|
+
flexBasis: number;
|
|
296
|
+
}) {
|
|
297
|
+
const style: CSSProperties = {
|
|
298
|
+
...element.props.style,
|
|
299
|
+
flexBasis: `${flexBasis}%`,
|
|
300
|
+
flexGrow: 0,
|
|
301
|
+
flexShrink: 0,
|
|
302
|
+
};
|
|
303
|
+
return <ResizablePanel {...element.props} style={style} />;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function ResizableHandleClone({
|
|
307
|
+
element,
|
|
308
|
+
...injected
|
|
309
|
+
}: {
|
|
310
|
+
element: ReactElement<ResizableHandleProps>;
|
|
311
|
+
} & ResizableHandleProps) {
|
|
312
|
+
return <ResizableHandle {...element.props} {...injected} />;
|
|
313
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../Resizable';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('ResizablePanelGroup', () => {
|
|
9
|
+
it('renders each panel and a separator between them', () => {
|
|
10
|
+
render(
|
|
11
|
+
<ResizablePanelGroup direction="horizontal">
|
|
12
|
+
<ResizablePanel>
|
|
13
|
+
<p>Left</p>
|
|
14
|
+
</ResizablePanel>
|
|
15
|
+
<ResizableHandle aria-label="Resize sidebar" />
|
|
16
|
+
<ResizablePanel>
|
|
17
|
+
<p>Right</p>
|
|
18
|
+
</ResizablePanel>
|
|
19
|
+
</ResizablePanelGroup>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByText('Left')).toBeDefined();
|
|
23
|
+
expect(screen.getByText('Right')).toBeDefined();
|
|
24
|
+
expect(screen.getByRole('separator', { name: 'Resize sidebar' })).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('splits panels evenly by default', () => {
|
|
28
|
+
render(
|
|
29
|
+
<ResizablePanelGroup direction="horizontal">
|
|
30
|
+
<ResizablePanel>
|
|
31
|
+
<p>Left</p>
|
|
32
|
+
</ResizablePanel>
|
|
33
|
+
<ResizableHandle aria-label="Resize" />
|
|
34
|
+
<ResizablePanel>
|
|
35
|
+
<p>Right</p>
|
|
36
|
+
</ResizablePanel>
|
|
37
|
+
</ResizablePanelGroup>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const separator = screen.getByRole('separator', { name: 'Resize' });
|
|
41
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('50');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('resizes the pair of neighboring panels with the keyboard, redistributing their combined size', () => {
|
|
45
|
+
render(
|
|
46
|
+
<ResizablePanelGroup direction="horizontal">
|
|
47
|
+
<ResizablePanel defaultSize={50} minSize={20} maxSize={80}>
|
|
48
|
+
<p>Left</p>
|
|
49
|
+
</ResizablePanel>
|
|
50
|
+
<ResizableHandle aria-label="Resize" />
|
|
51
|
+
<ResizablePanel defaultSize={50} minSize={20} maxSize={80}>
|
|
52
|
+
<p>Right</p>
|
|
53
|
+
</ResizablePanel>
|
|
54
|
+
</ResizablePanelGroup>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const separator = screen.getByRole('separator', { name: 'Resize' });
|
|
58
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight' });
|
|
59
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('55');
|
|
60
|
+
|
|
61
|
+
fireEvent.keyDown(separator, { key: 'ArrowLeft', shiftKey: true });
|
|
62
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('45');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('clamps keyboard resizing to minSize/maxSize', () => {
|
|
66
|
+
render(
|
|
67
|
+
<ResizablePanelGroup direction="horizontal">
|
|
68
|
+
<ResizablePanel defaultSize={50} minSize={45} maxSize={55}>
|
|
69
|
+
<p>Left</p>
|
|
70
|
+
</ResizablePanel>
|
|
71
|
+
<ResizableHandle aria-label="Resize" />
|
|
72
|
+
<ResizablePanel defaultSize={50} minSize={45} maxSize={55}>
|
|
73
|
+
<p>Right</p>
|
|
74
|
+
</ResizablePanel>
|
|
75
|
+
</ResizablePanelGroup>,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const separator = screen.getByRole('separator', { name: 'Resize' });
|
|
79
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
|
|
80
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
|
|
81
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
|
|
82
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('55');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('only affects the pair of panels adjacent to the handle that moved', () => {
|
|
86
|
+
render(
|
|
87
|
+
<ResizablePanelGroup direction="horizontal">
|
|
88
|
+
<ResizablePanel defaultSize={34}>
|
|
89
|
+
<p>A</p>
|
|
90
|
+
</ResizablePanel>
|
|
91
|
+
<ResizableHandle aria-label="Resize A-B" />
|
|
92
|
+
<ResizablePanel defaultSize={33}>
|
|
93
|
+
<p>B</p>
|
|
94
|
+
</ResizablePanel>
|
|
95
|
+
<ResizableHandle aria-label="Resize B-C" />
|
|
96
|
+
<ResizablePanel defaultSize={33}>
|
|
97
|
+
<p>C</p>
|
|
98
|
+
</ResizablePanel>
|
|
99
|
+
</ResizablePanelGroup>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const first = screen.getByRole('separator', { name: 'Resize A-B' });
|
|
103
|
+
const second = screen.getByRole('separator', { name: 'Resize B-C' });
|
|
104
|
+
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
|
105
|
+
expect(second.getAttribute('aria-valuenow')).toBe('28');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('uses ArrowDown/ArrowUp for vertical groups instead of ArrowRight/ArrowLeft', () => {
|
|
109
|
+
render(
|
|
110
|
+
<ResizablePanelGroup direction="vertical">
|
|
111
|
+
<ResizablePanel defaultSize={50}>
|
|
112
|
+
<p>Top</p>
|
|
113
|
+
</ResizablePanel>
|
|
114
|
+
<ResizableHandle aria-label="Resize" />
|
|
115
|
+
<ResizablePanel defaultSize={50}>
|
|
116
|
+
<p>Bottom</p>
|
|
117
|
+
</ResizablePanel>
|
|
118
|
+
</ResizablePanelGroup>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const separator = screen.getByRole('separator', { name: 'Resize' });
|
|
122
|
+
expect(separator.getAttribute('aria-orientation')).toBe('horizontal');
|
|
123
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight' });
|
|
124
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('50');
|
|
125
|
+
fireEvent.keyDown(separator, { key: 'ArrowDown' });
|
|
126
|
+
expect(separator.getAttribute('aria-valuenow')).toBe('55');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useResponsiveLayout } from '../../hooks/useResponsiveLayout';
|
|
3
|
+
|
|
4
|
+
export interface ResponsiveSurfaceProps {
|
|
5
|
+
web: ReactNode;
|
|
6
|
+
mobile: ReactNode;
|
|
7
|
+
breakpointPx?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ResponsiveSurface — thin JSX convenience wrapper around
|
|
12
|
+
* `useResponsiveLayout` for the common two-tree case: render an entirely
|
|
13
|
+
* different component tree below the breakpoint, not a CSS squeeze of the
|
|
14
|
+
* same one. Renders only whichever side is active — the other tree is never
|
|
15
|
+
* mounted.
|
|
16
|
+
*
|
|
17
|
+
* This does not replace a plugin's own shell component (e.g. a
|
|
18
|
+
* `MobileAwareShell` that also threads plugin-specific props like a refresh
|
|
19
|
+
* signal) — those stay a thin per-plugin wrapper around this.
|
|
20
|
+
*/
|
|
21
|
+
export function ResponsiveSurface({ web, mobile, breakpointPx }: ResponsiveSurfaceProps) {
|
|
22
|
+
const { value } = useResponsiveLayout({ web, mobile, breakpointPx });
|
|
23
|
+
return <>{value}</>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { ResponsiveSurface } from '../ResponsiveSurface';
|
|
5
|
+
|
|
6
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
|
7
|
+
let matches = false;
|
|
8
|
+
|
|
9
|
+
function installMatchMedia() {
|
|
10
|
+
changeHandler = null;
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'matchMedia',
|
|
13
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
14
|
+
matches,
|
|
15
|
+
media: query,
|
|
16
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
17
|
+
changeHandler = cb;
|
|
18
|
+
},
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
addListener: vi.fn(),
|
|
21
|
+
removeListener: vi.fn(),
|
|
22
|
+
dispatchEvent: vi.fn(),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ResponsiveSurface', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
matches = false;
|
|
30
|
+
installMatchMedia();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders the web tree by default and never mounts the mobile tree', () => {
|
|
38
|
+
render(
|
|
39
|
+
<ResponsiveSurface
|
|
40
|
+
web={<div data-testid="web">web</div>}
|
|
41
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByTestId('web')).toBeDefined();
|
|
45
|
+
expect(screen.queryByTestId('mobile')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders only the mobile tree below the breakpoint', () => {
|
|
49
|
+
matches = true;
|
|
50
|
+
installMatchMedia();
|
|
51
|
+
render(
|
|
52
|
+
<ResponsiveSurface
|
|
53
|
+
web={<div data-testid="web">web</div>}
|
|
54
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByTestId('mobile')).toBeDefined();
|
|
58
|
+
expect(screen.queryByTestId('web')).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('swaps trees live when the viewport crosses the breakpoint', () => {
|
|
62
|
+
render(
|
|
63
|
+
<ResponsiveSurface
|
|
64
|
+
web={<div data-testid="web">web</div>}
|
|
65
|
+
mobile={<div data-testid="mobile">mobile</div>}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId('web')).toBeDefined();
|
|
69
|
+
act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
|
|
70
|
+
expect(screen.getByTestId('mobile')).toBeDefined();
|
|
71
|
+
expect(screen.queryByTestId('web')).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
overflow: auto;
|
|
3
|
+
overscroll-behavior: contain;
|
|
4
|
+
scrollbar-width: thin;
|
|
5
|
+
scrollbar-color: var(--sv-color-border-strong) transparent;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.root::-webkit-scrollbar {
|
|
9
|
+
width: 8px;
|
|
10
|
+
height: 8px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.root::-webkit-scrollbar-track {
|
|
14
|
+
background: transparent;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.root::-webkit-scrollbar-thumb {
|
|
18
|
+
background: var(--sv-color-border-strong);
|
|
19
|
+
border-radius: var(--sv-radius-full);
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ScrollArea } from './ScrollArea';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ScrollArea',
|
|
6
|
+
component: ScrollArea,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: { children: null },
|
|
9
|
+
} satisfies Meta<typeof ScrollArea>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const Vertical: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<div style={{ width: 280 }}>
|
|
19
|
+
<ScrollArea maxHeight={160}>
|
|
20
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 4 }}>
|
|
21
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
22
|
+
<div key={i} style={{ fontSize: 14 }}>
|
|
23
|
+
Row {i + 1}
|
|
24
|
+
</div>
|
|
25
|
+
))}
|
|
26
|
+
</div>
|
|
27
|
+
</ScrollArea>
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Horizontal: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<ScrollArea maxWidth={320}>
|
|
35
|
+
<div style={{ display: 'flex', gap: 8, padding: 4, width: 'max-content' }}>
|
|
36
|
+
{Array.from({ length: 12 }, (_, i) => (
|
|
37
|
+
<div
|
|
38
|
+
key={i}
|
|
39
|
+
style={{
|
|
40
|
+
width: 80,
|
|
41
|
+
height: 60,
|
|
42
|
+
flexShrink: 0,
|
|
43
|
+
background: 'var(--sv-color-surface-sunken)',
|
|
44
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
45
|
+
display: 'flex',
|
|
46
|
+
alignItems: 'center',
|
|
47
|
+
justifyContent: 'center',
|
|
48
|
+
fontSize: 12,
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{i + 1}
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</ScrollArea>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import styles from './ScrollArea.module.css';
|
|
3
|
+
|
|
4
|
+
export interface ScrollAreaProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Constrains the scrollable direction; content taller/wider than this scrolls. */
|
|
7
|
+
maxHeight?: string | number;
|
|
8
|
+
maxWidth?: string | number;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ScrollArea — a styled scrollable container (thin, token-colored scrollbar
|
|
14
|
+
* instead of the OS default). Native overflow scrolling underneath — no
|
|
15
|
+
* virtualization, no custom scroll physics.
|
|
16
|
+
*/
|
|
17
|
+
export function ScrollArea({ children, maxHeight, maxWidth, className }: ScrollAreaProps) {
|
|
18
|
+
const style: CSSProperties = {};
|
|
19
|
+
if (maxHeight !== undefined) style.maxHeight = maxHeight;
|
|
20
|
+
if (maxWidth !== undefined) style.maxWidth = maxWidth;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { ScrollArea } from '../ScrollArea';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('ScrollArea', () => {
|
|
9
|
+
it('renders children', () => {
|
|
10
|
+
render(
|
|
11
|
+
<ScrollArea>
|
|
12
|
+
<div>content</div>
|
|
13
|
+
</ScrollArea>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByText('content')).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('applies maxHeight/maxWidth as inline styles when given', () => {
|
|
19
|
+
render(
|
|
20
|
+
<ScrollArea maxHeight={200} maxWidth="100%">
|
|
21
|
+
<div data-testid="child">content</div>
|
|
22
|
+
</ScrollArea>,
|
|
23
|
+
);
|
|
24
|
+
const root = screen.getByTestId('child').parentElement;
|
|
25
|
+
expect(root?.style.maxHeight).toBe('200px');
|
|
26
|
+
expect(root?.style.maxWidth).toBe('100%');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does not set maxHeight/maxWidth styles when omitted', () => {
|
|
30
|
+
render(
|
|
31
|
+
<ScrollArea>
|
|
32
|
+
<div data-testid="child">content</div>
|
|
33
|
+
</ScrollArea>,
|
|
34
|
+
);
|
|
35
|
+
const root = screen.getByTestId('child').parentElement;
|
|
36
|
+
expect(root?.style.maxHeight).toBe('');
|
|
37
|
+
expect(root?.style.maxWidth).toBe('');
|
|
38
|
+
});
|
|
39
|
+
});
|