@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
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
.select {
|
|
9
9
|
display: block;
|
|
10
10
|
width: 100%;
|
|
11
|
-
/*
|
|
12
|
-
|
|
11
|
+
/* 36px rendered height. Explicit height + border-box, same technique as
|
|
12
|
+
Input. Right-pad to clear the custom chevron. */
|
|
13
|
+
height: 36px;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
padding: 4px var(--sv-space-8) 4px var(--sv-space-3);
|
|
13
16
|
font-family: var(--sv-font-family);
|
|
14
17
|
font-size: var(--sv-font-size-sm);
|
|
15
18
|
line-height: 1.5;
|
|
@@ -25,8 +28,9 @@
|
|
|
25
28
|
box-shadow 0.15s ease;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
/* sm — compact for table cells and inline controls */
|
|
31
|
+
/* sm — compact for table cells and inline controls. 32px height. */
|
|
29
32
|
.sm .select {
|
|
33
|
+
height: 32px;
|
|
30
34
|
padding: 2px var(--sv-space-6) 2px var(--sv-space-2);
|
|
31
35
|
font-size: var(--sv-font-size-sm);
|
|
32
36
|
line-height: 1.4;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode,
|
|
4
|
-
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
3
|
+
import { type ReactNode, useRef } from 'react';
|
|
5
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
|
+
import {
|
|
6
|
+
useOverlayFocusCapture,
|
|
7
|
+
useOverlayKeyboardTrap,
|
|
8
|
+
useOverlayScrollLock,
|
|
9
|
+
} from '../../overlay-shell';
|
|
6
10
|
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
7
11
|
import styles from './Sheet.module.css';
|
|
8
12
|
|
|
@@ -36,9 +40,6 @@ export interface SheetProps {
|
|
|
36
40
|
children: ReactNode;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const FOCUSABLE =
|
|
40
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
-
|
|
42
43
|
// Matches --sv-motion-duration-base (Sheet.module.css) — see Dialog.tsx's
|
|
43
44
|
// identical constant for why this stays a plain JS number.
|
|
44
45
|
const MOTION_DURATION_MS = 250;
|
|
@@ -71,55 +72,12 @@ export function Sheet({
|
|
|
71
72
|
children,
|
|
72
73
|
}: SheetProps) {
|
|
73
74
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
-
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
75
75
|
const reducedMotion = usePrefersReducedMotion();
|
|
76
76
|
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return unlockBodyScroll;
|
|
82
|
-
}, [mounted]);
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!mounted) return;
|
|
86
|
-
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
87
|
-
const panel = panelRef.current;
|
|
88
|
-
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
89
|
-
(first ?? panel)?.focus();
|
|
90
|
-
return () => previouslyFocused.current?.focus();
|
|
91
|
-
}, [mounted]);
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (!open) return;
|
|
95
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
96
|
-
if (e.key === 'Escape') {
|
|
97
|
-
e.stopPropagation();
|
|
98
|
-
onClose();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (e.key !== 'Tab') return;
|
|
102
|
-
const panel = panelRef.current;
|
|
103
|
-
if (!panel) return;
|
|
104
|
-
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
105
|
-
const first = focusable[0];
|
|
106
|
-
const last = focusable.at(-1);
|
|
107
|
-
if (!first || !last) {
|
|
108
|
-
e.preventDefault();
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
const active = document.activeElement;
|
|
112
|
-
if (e.shiftKey && active === first) {
|
|
113
|
-
e.preventDefault();
|
|
114
|
-
last.focus();
|
|
115
|
-
} else if (!e.shiftKey && active === last) {
|
|
116
|
-
e.preventDefault();
|
|
117
|
-
first.focus();
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
121
|
-
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
122
|
-
}, [open, onClose]);
|
|
78
|
+
useOverlayScrollLock(mounted);
|
|
79
|
+
useOverlayFocusCapture(panelRef, mounted);
|
|
80
|
+
useOverlayKeyboardTrap(panelRef, open, onClose);
|
|
123
81
|
|
|
124
82
|
if (!mounted) return null;
|
|
125
83
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--sv-space-2);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.label {
|
|
8
|
+
font-size: var(--sv-font-size-sm);
|
|
9
|
+
color: var(--sv-color-text-primary);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.slider {
|
|
13
|
+
appearance: none;
|
|
14
|
+
width: 100%;
|
|
15
|
+
height: 20px;
|
|
16
|
+
background: transparent;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
margin: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Expand the vertical hit area for touch dragging without thickening the
|
|
22
|
+
visible track — same rationale as Button/Checkbox's touch-target
|
|
23
|
+
expansion, gated to the primary pointer. */
|
|
24
|
+
@media (pointer: coarse) {
|
|
25
|
+
.slider {
|
|
26
|
+
height: var(--sv-touch-target-min, 44px);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.slider:disabled {
|
|
31
|
+
opacity: 0.5;
|
|
32
|
+
cursor: not-allowed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.slider:focus-visible {
|
|
36
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Track — WebKit/Blink. The filled portion is a gradient computed from the
|
|
41
|
+
--slider-fill custom property the component sets inline; no JS-measured
|
|
42
|
+
pixel math, just a CSS variable driving a hard color stop. */
|
|
43
|
+
.slider::-webkit-slider-runnable-track {
|
|
44
|
+
height: 4px;
|
|
45
|
+
border-radius: var(--sv-radius-full);
|
|
46
|
+
background: linear-gradient(
|
|
47
|
+
to right,
|
|
48
|
+
var(--sv-color-accent) var(--slider-fill),
|
|
49
|
+
var(--sv-color-border-strong) var(--slider-fill)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.slider::-webkit-slider-thumb {
|
|
54
|
+
appearance: none;
|
|
55
|
+
width: 16px;
|
|
56
|
+
height: 16px;
|
|
57
|
+
margin-top: -6px;
|
|
58
|
+
border-radius: var(--sv-radius-full);
|
|
59
|
+
background: var(--sv-color-accent);
|
|
60
|
+
box-shadow: var(--sv-shadow-control);
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.slider:disabled::-webkit-slider-thumb {
|
|
65
|
+
cursor: not-allowed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Track — Firefox. Separate track/progress pseudo-elements instead of a
|
|
69
|
+
gradient — Firefox renders the filled portion natively via
|
|
70
|
+
::-moz-range-progress. */
|
|
71
|
+
.slider::-moz-range-track {
|
|
72
|
+
height: 4px;
|
|
73
|
+
border-radius: var(--sv-radius-full);
|
|
74
|
+
background: var(--sv-color-border-strong);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.slider::-moz-range-progress {
|
|
78
|
+
height: 4px;
|
|
79
|
+
border-radius: var(--sv-radius-full);
|
|
80
|
+
background: var(--sv-color-accent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.slider::-moz-range-thumb {
|
|
84
|
+
width: 16px;
|
|
85
|
+
height: 16px;
|
|
86
|
+
border: none;
|
|
87
|
+
border-radius: var(--sv-radius-full);
|
|
88
|
+
background: var(--sv-color-accent);
|
|
89
|
+
box-shadow: var(--sv-shadow-control);
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.slider:disabled::-moz-range-thumb {
|
|
94
|
+
cursor: not-allowed;
|
|
95
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { Slider } from './Slider';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Slider',
|
|
7
|
+
component: Slider,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Single-thumb range input. A native <input type="range"> under custom styling — arrow keys, Home/End, and touch-drag all come from the browser.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
value: 50,
|
|
19
|
+
onChange: () => {},
|
|
20
|
+
min: 0,
|
|
21
|
+
max: 100,
|
|
22
|
+
'aria-label': 'Value',
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof Slider>;
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof meta>;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
render: (args) => {
|
|
33
|
+
const [value, setValue] = useState(args.value);
|
|
34
|
+
return <Slider {...args} value={value} onChange={setValue} />;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithLabel: Story = {
|
|
39
|
+
render: (args) => {
|
|
40
|
+
const [value, setValue] = useState(args.value);
|
|
41
|
+
return <Slider {...args} label="Volume" value={value} onChange={setValue} />;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const AtMin: Story = {
|
|
46
|
+
args: { value: 0 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const AtMax: Story = {
|
|
50
|
+
args: { value: 100 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const SteppedRange: Story = {
|
|
54
|
+
render: (_args) => {
|
|
55
|
+
const [value, setValue] = useState(5);
|
|
56
|
+
return (
|
|
57
|
+
<Slider value={value} onChange={setValue} min={0} max={10} step={1} label="Rating (0–10)" />
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Disabled: Story = {
|
|
63
|
+
args: { disabled: true },
|
|
64
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react';
|
|
4
|
+
import type { CSSProperties } from 'react';
|
|
5
|
+
import styles from './Slider.module.css';
|
|
6
|
+
|
|
7
|
+
export interface SliderProps {
|
|
8
|
+
value: number;
|
|
9
|
+
onChange: (value: number) => void;
|
|
10
|
+
min: number;
|
|
11
|
+
max: number;
|
|
12
|
+
step?: number;
|
|
13
|
+
/** Renders a visible label above the track. Omit and pass `aria-label`
|
|
14
|
+
* instead for a standalone, unlabeled slider. */
|
|
15
|
+
label?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
id?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
'aria-label'?: string;
|
|
20
|
+
'aria-describedby'?: string;
|
|
21
|
+
'aria-invalid'?: boolean;
|
|
22
|
+
required?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Slider — single-thumb range input.
|
|
27
|
+
*
|
|
28
|
+
* A native `<input type="range">` under custom track/thumb styling — arrow
|
|
29
|
+
* key (adjust by `step`), Home/End (jump to min/max), and touch-drag
|
|
30
|
+
* support all come from the browser, nothing hand-rolled. Dual-thumb/range
|
|
31
|
+
* selection is out of scope until a consumer needs it.
|
|
32
|
+
*/
|
|
33
|
+
export function Slider({
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
min,
|
|
37
|
+
max,
|
|
38
|
+
step,
|
|
39
|
+
label,
|
|
40
|
+
disabled,
|
|
41
|
+
id,
|
|
42
|
+
className,
|
|
43
|
+
'aria-label': ariaLabel,
|
|
44
|
+
'aria-describedby': ariaDescribedBy,
|
|
45
|
+
'aria-invalid': ariaInvalid,
|
|
46
|
+
required,
|
|
47
|
+
}: SliderProps) {
|
|
48
|
+
const generatedId = useId();
|
|
49
|
+
const inputId = id ?? generatedId;
|
|
50
|
+
const fillPercent = max > min ? ((value - min) / (max - min)) * 100 : 0;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
54
|
+
{label && (
|
|
55
|
+
<label htmlFor={inputId} className={styles.label}>
|
|
56
|
+
{label}
|
|
57
|
+
</label>
|
|
58
|
+
)}
|
|
59
|
+
<input
|
|
60
|
+
id={inputId}
|
|
61
|
+
type="range"
|
|
62
|
+
min={min}
|
|
63
|
+
max={max}
|
|
64
|
+
step={step}
|
|
65
|
+
value={value}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
required={required}
|
|
68
|
+
aria-label={label ? undefined : ariaLabel}
|
|
69
|
+
aria-describedby={ariaDescribedBy}
|
|
70
|
+
aria-invalid={ariaInvalid}
|
|
71
|
+
className={styles.slider}
|
|
72
|
+
style={{ '--slider-fill': `${fillPercent}%` } as CSSProperties}
|
|
73
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Slider } from '../Slider';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Slider', () => {
|
|
9
|
+
it('renders a range input with the given min/max/value', () => {
|
|
10
|
+
render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />);
|
|
11
|
+
const input = screen.getByRole('slider') as HTMLInputElement;
|
|
12
|
+
expect(input.min).toBe('0');
|
|
13
|
+
expect(input.max).toBe('100');
|
|
14
|
+
expect(input.value).toBe('50');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('calls onChange with a number when the value changes', () => {
|
|
18
|
+
const onChange = vi.fn();
|
|
19
|
+
render(<Slider value={50} onChange={onChange} min={0} max={100} aria-label="Volume" />);
|
|
20
|
+
fireEvent.change(screen.getByRole('slider'), { target: { value: '75' } });
|
|
21
|
+
expect(onChange).toHaveBeenCalledWith(75);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('respects a step prop', () => {
|
|
25
|
+
render(<Slider value={5} onChange={() => {}} min={0} max={10} step={5} aria-label="Volume" />);
|
|
26
|
+
expect((screen.getByRole('slider') as HTMLInputElement).step).toBe('5');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders a visible label and associates it via htmlFor', () => {
|
|
30
|
+
render(<Slider value={50} onChange={() => {}} min={0} max={100} label="Volume" />);
|
|
31
|
+
expect(screen.getByRole('slider', { name: 'Volume' })).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('falls back to aria-label when no visible label is given', () => {
|
|
35
|
+
render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />);
|
|
36
|
+
expect(screen.getByRole('slider', { name: 'Volume' })).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('disables the input when disabled is set', () => {
|
|
40
|
+
render(
|
|
41
|
+
<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" disabled />,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByRole('slider')).toHaveProperty('disabled', true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
.wrap {
|
|
2
|
+
position: relative;
|
|
3
|
+
height: 100%;
|
|
4
|
+
width: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.scroller {
|
|
8
|
+
height: 100%;
|
|
9
|
+
width: 100%;
|
|
10
|
+
display: flex;
|
|
11
|
+
overflow-x: auto;
|
|
12
|
+
overflow-y: hidden;
|
|
13
|
+
scroll-snap-type: x mandatory;
|
|
14
|
+
/* Momentum + rubber-banding come from the browser's native scroll physics —
|
|
15
|
+
no hand-rolled pointer/touch dragging. Deliberately NOT
|
|
16
|
+
-webkit-overflow-scrolling: touch: that legacy iOS <13 hint promotes its
|
|
17
|
+
element to its own compositing layer for GPU-accelerated momentum
|
|
18
|
+
scroll, and that layer's paint order relative to siblings/fixed
|
|
19
|
+
descendants elsewhere in the tree isn't always reliable on real iOS
|
|
20
|
+
Safari (confirmed via sovereign-tasks' and sovereign-shopper's own
|
|
21
|
+
carousels, which hit and fixed this exact bug — a position:fixed
|
|
22
|
+
overlay opened above the scroller painted BEHIND it instead of above).
|
|
23
|
+
Removing it doesn't disable momentum scrolling on any iOS version this
|
|
24
|
+
platform realistically targets. */
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.slide {
|
|
28
|
+
flex: 0 0 100%;
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 100%;
|
|
31
|
+
min-width: 0;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
scroll-snap-align: start;
|
|
34
|
+
scroll-snap-stop: always;
|
|
35
|
+
/* Slide content is a flex column so Header/Footer stay their natural size
|
|
36
|
+
and Body (flex: 1 1 auto, min-height: 0) fills and scrolls the rest —
|
|
37
|
+
see SwipableMobileCarouselSlideParts.module.css. */
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.dots {
|
|
43
|
+
/* Overlays the bottom of .wrap rather than taking its own layout row —
|
|
44
|
+
matches both sovereign-tasks' and sovereign-shopper's existing dots
|
|
45
|
+
placement. */
|
|
46
|
+
position: absolute;
|
|
47
|
+
left: 0;
|
|
48
|
+
right: 0;
|
|
49
|
+
bottom: var(--sv-space-3);
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.dots > * {
|
|
54
|
+
pointer-events: auto;
|
|
55
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Children,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { useSnapCarousel } from '../../hooks/useSnapCarousel';
|
|
14
|
+
import { SwipableMobileCarouselDots } from '../SwipableMobileCarouselDots/SwipableMobileCarouselDots';
|
|
15
|
+
import { SwipableMobileCarouselSlide } from './SwipableMobileCarouselSlide';
|
|
16
|
+
import { CarouselSlideMountContext } from './context';
|
|
17
|
+
import styles from './SwipableMobileCarousel.module.css';
|
|
18
|
+
|
|
19
|
+
export interface SwipableMobileCarouselIndicatorProps {
|
|
20
|
+
count: number;
|
|
21
|
+
activeIndex: number;
|
|
22
|
+
labels: (string | undefined)[];
|
|
23
|
+
onJump: (index: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SwipableMobileCarouselProps {
|
|
27
|
+
/** Controlled active slide index — the caller owns this (typically via
|
|
28
|
+
* useCarouselRouteSync, or plain useState). */
|
|
29
|
+
activeIndex: number;
|
|
30
|
+
/** Called when a swipe gesture settles on a different index, or when the
|
|
31
|
+
* reorder-jump fix re-snaps after the active slide's position changes.
|
|
32
|
+
* The caller is responsible for updating `activeIndex` in response. */
|
|
33
|
+
onSettle: (index: number) => void;
|
|
34
|
+
/** How many slides on each side of the active one stay mounted. Default 1
|
|
35
|
+
* matches both sovereign-tasks' and sovereign-shopper's existing "swipe
|
|
36
|
+
* never shows a spinner" behavior. Slides outside this window keep their
|
|
37
|
+
* DOM slot (so scroll-snap's position-based indexing stays correct) but
|
|
38
|
+
* their children are not mounted at all. */
|
|
39
|
+
prefetchDistance?: number;
|
|
40
|
+
/** Passed straight through to the internal useSnapCarousel. */
|
|
41
|
+
settleDebounceMs?: number;
|
|
42
|
+
/** Required — labels the scroller region for assistive tech. No generic
|
|
43
|
+
* fallback, since one would be wrong for every actual consumer. */
|
|
44
|
+
'aria-label': string;
|
|
45
|
+
/** Renders the indicator. Defaults to SwipableMobileCarouselDots. Pass
|
|
46
|
+
* `null` to render no indicator at all. */
|
|
47
|
+
renderIndicator?: null | ((props: SwipableMobileCarouselIndicatorProps) => ReactNode);
|
|
48
|
+
className?: string;
|
|
49
|
+
/** Must be SwipableMobileCarouselSlide elements (nullish/boolean children
|
|
50
|
+
* are safely skipped for conditional slides). A dev-mode warning is
|
|
51
|
+
* logged for any other child type — see resolveSlides below. */
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ResolvedSlide {
|
|
56
|
+
key: string;
|
|
57
|
+
label: string | undefined;
|
|
58
|
+
element: ReactElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeChild(child: unknown): string {
|
|
62
|
+
if (isValidElement(child)) {
|
|
63
|
+
const type = child.type;
|
|
64
|
+
if (typeof type === 'string') return `<${type}>`;
|
|
65
|
+
if (typeof type === 'function') {
|
|
66
|
+
const named = type as { displayName?: string; name?: string };
|
|
67
|
+
return `<${named.displayName ?? named.name ?? 'Component'}>`;
|
|
68
|
+
}
|
|
69
|
+
return '<unknown>';
|
|
70
|
+
}
|
|
71
|
+
if (typeof child === 'string') return `string "${child}"`;
|
|
72
|
+
return String(child);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveSlides(children: ReactNode): ResolvedSlide[] {
|
|
76
|
+
const slides: ResolvedSlide[] = [];
|
|
77
|
+
Children.forEach(children, (child, index) => {
|
|
78
|
+
if (child === null || child === undefined || child === false || child === true) return;
|
|
79
|
+
if (!isValidElement(child) || child.type !== SwipableMobileCarouselSlide) {
|
|
80
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
81
|
+
console.error(
|
|
82
|
+
'[SwipableMobileCarousel] Every child must be a <SwipableMobileCarouselSlide>. ' +
|
|
83
|
+
`Found ${describeChild(child)} at position ${index}. A non-Slide child shifts every ` +
|
|
84
|
+
"subsequent slide's index, desyncing scroll-snap position from activeIndex.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const slideProps = child.props as { slideKey: string; label?: string };
|
|
90
|
+
if (process.env.NODE_ENV !== 'production' && !slideProps.label) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[SwipableMobileCarousel] SwipableMobileCarouselSlide "${slideProps.slideKey}" has no ` +
|
|
93
|
+
'label — it will show as "Slide N of count" in the default dots indicator.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
slides.push({ key: slideProps.slideKey, label: slideProps.label, element: child });
|
|
97
|
+
});
|
|
98
|
+
return slides;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* SwipableMobileCarousel — a compound component for swiping between full-
|
|
103
|
+
* width slides (each an independent route/view on mobile), wrapping
|
|
104
|
+
* useSnapCarousel. This library's first compound component: every other
|
|
105
|
+
* component here is flat/prop-based, but slide content is large, arbitrary,
|
|
106
|
+
* per-plugin subtrees that need a header-known/body-loading split an
|
|
107
|
+
* array-of-objects prop would only express with worse JSX ergonomics.
|
|
108
|
+
*
|
|
109
|
+
* Owns rendering and mount-window mechanics only — it has no opinion on
|
|
110
|
+
* where slide data lives. Do NOT: (a) aggregate cross-slide data inside a
|
|
111
|
+
* SwipableMobileCarouselSlideBody (that recomputes on every slide render
|
|
112
|
+
* regardless of which slide is active — belongs in the parent, which
|
|
113
|
+
* already knows every slide exists); (b) mount a detail overlay (Sheet,
|
|
114
|
+
* Dialog) inside a Slide's children — mount it as a sibling of
|
|
115
|
+
* SwipableMobileCarousel instead, controlled by the same state that would
|
|
116
|
+
* otherwise drive a routed page's overlay. sovereign-tasks' current
|
|
117
|
+
* hand-rolled carousel does both and is measurably laggier than
|
|
118
|
+
* sovereign-shopper's equivalent, which keeps its overlay in the routed pane.
|
|
119
|
+
*/
|
|
120
|
+
export function SwipableMobileCarousel({
|
|
121
|
+
activeIndex,
|
|
122
|
+
onSettle,
|
|
123
|
+
prefetchDistance = 1,
|
|
124
|
+
settleDebounceMs = 120,
|
|
125
|
+
'aria-label': ariaLabel,
|
|
126
|
+
renderIndicator,
|
|
127
|
+
className,
|
|
128
|
+
children,
|
|
129
|
+
}: SwipableMobileCarouselProps) {
|
|
130
|
+
const slides = useMemo(() => resolveSlides(children), [children]);
|
|
131
|
+
const count = slides.length;
|
|
132
|
+
const clampedActiveIndex = count === 0 ? 0 : Math.max(0, Math.min(count - 1, activeIndex));
|
|
133
|
+
|
|
134
|
+
const { scrollRef, scrollToIndex } = useSnapCarousel({
|
|
135
|
+
itemCount: count,
|
|
136
|
+
onSettle,
|
|
137
|
+
debounceMs: settleDebounceMs,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Initial scroll position, once — subsequent activeIndex changes come
|
|
141
|
+
// either from the user's own swipe (already reflected in DOM scroll
|
|
142
|
+
// position; re-syncing to the same index below is a harmless no-op) or an
|
|
143
|
+
// external navigation, which the effect below scrolls to explicitly.
|
|
144
|
+
const initialActiveIndexRef = useRef(clampedActiveIndex);
|
|
145
|
+
useLayoutEffect(() => {
|
|
146
|
+
scrollToIndex(initialActiveIndexRef.current, 'instant');
|
|
147
|
+
// Runs once on mount only, matching both existing plugins' identical pattern.
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const didMountActiveIndexSyncRef = useRef(false);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!didMountActiveIndexSyncRef.current) {
|
|
153
|
+
didMountActiveIndexSyncRef.current = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
scrollToIndex(clampedActiveIndex, 'smooth');
|
|
157
|
+
}, [clampedActiveIndex, scrollToIndex]);
|
|
158
|
+
|
|
159
|
+
// Reorder-jump fix: scroll-snap position tracks DOM order, not slide
|
|
160
|
+
// identity. If the previously-active slide's key still exists but moved
|
|
161
|
+
// (a caller re-sorted their slide array while mounted), re-snap to its
|
|
162
|
+
// new position and report it via onSettle so the caller's activeIndex
|
|
163
|
+
// stays in sync — otherwise scroll position silently desyncs from slide
|
|
164
|
+
// identity. Runs every commit (no dependency array) since it needs to
|
|
165
|
+
// notice any children change, not just a specific prop.
|
|
166
|
+
const prevKeysRef = useRef<string[] | null>(null);
|
|
167
|
+
useLayoutEffect(() => {
|
|
168
|
+
const currentKeys = slides.map((s) => s.key);
|
|
169
|
+
const prevKeys = prevKeysRef.current;
|
|
170
|
+
if (prevKeys) {
|
|
171
|
+
const sameOrder =
|
|
172
|
+
prevKeys.length === currentKeys.length && prevKeys.every((k, i) => k === currentKeys[i]);
|
|
173
|
+
if (!sameOrder) {
|
|
174
|
+
const activeKey = prevKeys[clampedActiveIndex];
|
|
175
|
+
if (activeKey !== undefined) {
|
|
176
|
+
const newIndex = currentKeys.indexOf(activeKey);
|
|
177
|
+
if (newIndex !== -1 && newIndex !== clampedActiveIndex) {
|
|
178
|
+
scrollToIndex(newIndex, 'instant');
|
|
179
|
+
onSettle(newIndex);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
prevKeysRef.current = currentKeys;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function handleJump(index: number) {
|
|
188
|
+
// scrollToIndex sets useSnapCarousel's own dedupe ref synchronously, so
|
|
189
|
+
// the natural scroll-settle detection will NOT re-report this index —
|
|
190
|
+
// onSettle must be called directly here, same pairing as the
|
|
191
|
+
// reorder-jump fix above.
|
|
192
|
+
scrollToIndex(index, 'smooth');
|
|
193
|
+
onSettle(index);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const labels = slides.map((s) => s.label);
|
|
197
|
+
const indicator =
|
|
198
|
+
renderIndicator === null ? null : renderIndicator ? (
|
|
199
|
+
renderIndicator({ count, activeIndex: clampedActiveIndex, labels, onJump: handleJump })
|
|
200
|
+
) : (
|
|
201
|
+
<SwipableMobileCarouselDots
|
|
202
|
+
className={styles.dots}
|
|
203
|
+
count={count}
|
|
204
|
+
activeIndex={clampedActiveIndex}
|
|
205
|
+
labels={labels}
|
|
206
|
+
aria-label={ariaLabel}
|
|
207
|
+
onJump={handleJump}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className={[styles.wrap, className].filter(Boolean).join(' ')}>
|
|
213
|
+
<div className={styles.scroller} ref={scrollRef} role="region" aria-label={ariaLabel}>
|
|
214
|
+
{slides.map((slide, i) => (
|
|
215
|
+
<div className={styles.slide} key={slide.key}>
|
|
216
|
+
<CarouselSlideMountContext.Provider
|
|
217
|
+
value={{ isMounted: Math.abs(i - clampedActiveIndex) <= prefetchDistance }}
|
|
218
|
+
>
|
|
219
|
+
{slide.element}
|
|
220
|
+
</CarouselSlideMountContext.Provider>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
{indicator}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|