@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,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useResponsiveLayout } from '../useResponsiveLayout';
|
|
5
|
+
|
|
6
|
+
// Same matchMedia mocking approach as useIsMobile.test.tsx, since this hook
|
|
7
|
+
// is a thin wrapper over it and must not diverge in SSR/breakpoint behavior.
|
|
8
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
|
9
|
+
let matches = false;
|
|
10
|
+
let lastQuery = '';
|
|
11
|
+
|
|
12
|
+
function installMatchMedia() {
|
|
13
|
+
changeHandler = null;
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'matchMedia',
|
|
16
|
+
vi.fn().mockImplementation((query: string) => {
|
|
17
|
+
lastQuery = query;
|
|
18
|
+
return {
|
|
19
|
+
matches,
|
|
20
|
+
media: query,
|
|
21
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
22
|
+
changeHandler = cb;
|
|
23
|
+
},
|
|
24
|
+
removeEventListener: vi.fn(),
|
|
25
|
+
addListener: vi.fn(),
|
|
26
|
+
removeListener: vi.fn(),
|
|
27
|
+
dispatchEvent: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Harness({ breakpointPx }: { breakpointPx?: number }) {
|
|
34
|
+
const { isMobile, value } = useResponsiveLayout({
|
|
35
|
+
web: 'web-tree',
|
|
36
|
+
mobile: 'mobile-tree',
|
|
37
|
+
breakpointPx,
|
|
38
|
+
});
|
|
39
|
+
return (
|
|
40
|
+
<span data-testid="out" data-is-mobile={String(isMobile)}>
|
|
41
|
+
{value}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('useResponsiveLayout', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
matches = false;
|
|
49
|
+
installMatchMedia();
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('defaults to the web value before/without a mobile match (SSR-safe default)', () => {
|
|
57
|
+
matches = false;
|
|
58
|
+
installMatchMedia();
|
|
59
|
+
const { getByTestId } = render(<Harness />);
|
|
60
|
+
expect(getByTestId('out').textContent).toBe('web-tree');
|
|
61
|
+
expect(getByTestId('out').dataset.isMobile).toBe('false');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('picks the mobile value once matchMedia reports a match', () => {
|
|
65
|
+
matches = true;
|
|
66
|
+
installMatchMedia();
|
|
67
|
+
const { getByTestId } = render(<Harness />);
|
|
68
|
+
expect(getByTestId('out').textContent).toBe('mobile-tree');
|
|
69
|
+
expect(getByTestId('out').dataset.isMobile).toBe('true');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('swaps live when the viewport crosses the breakpoint', () => {
|
|
73
|
+
matches = false;
|
|
74
|
+
installMatchMedia();
|
|
75
|
+
const { getByTestId } = render(<Harness />);
|
|
76
|
+
expect(getByTestId('out').textContent).toBe('web-tree');
|
|
77
|
+
act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
|
|
78
|
+
expect(getByTestId('out').textContent).toBe('mobile-tree');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('passes a custom breakpointPx straight through to useIsMobile', () => {
|
|
82
|
+
matches = false;
|
|
83
|
+
installMatchMedia();
|
|
84
|
+
render(<Harness breakpointPx={640} />);
|
|
85
|
+
expect(lastQuery).toBe('(max-width: 640px)');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useSnapCarousel, type UseSnapCarouselOptions } from '../useSnapCarousel';
|
|
5
|
+
|
|
6
|
+
// jsdom doesn't implement scrollTo on elements.
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
if (!Element.prototype.scrollTo) {
|
|
10
|
+
Element.prototype.scrollTo = vi.fn();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.runOnlyPendingTimers();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function setLayout(el: HTMLElement, clientWidth: number, scrollLeft: number) {
|
|
21
|
+
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
|
|
22
|
+
Object.defineProperty(el, 'scrollLeft', { value: scrollLeft, configurable: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Harness(
|
|
26
|
+
props: UseSnapCarouselOptions & { onScrollTo?: (fn: (i: number) => void) => void },
|
|
27
|
+
) {
|
|
28
|
+
const { onScrollTo, ...options } = props;
|
|
29
|
+
const { scrollRef, scrollToIndex } = useSnapCarousel(options);
|
|
30
|
+
onScrollTo?.(scrollToIndex);
|
|
31
|
+
return <div data-testid="scroller" ref={scrollRef} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useSnapCarousel', () => {
|
|
35
|
+
it('calls onSettle with the new index after the debounce window', () => {
|
|
36
|
+
const onSettle = vi.fn();
|
|
37
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
38
|
+
const el = getByTestId('scroller');
|
|
39
|
+
setLayout(el, 300, 300); // scrollLeft 300 / width 300 -> index 1
|
|
40
|
+
fireEvent.scroll(el);
|
|
41
|
+
expect(onSettle).not.toHaveBeenCalled();
|
|
42
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
43
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('debounces repeated scroll events into a single settle call', () => {
|
|
47
|
+
const onSettle = vi.fn();
|
|
48
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
49
|
+
const el = getByTestId('scroller');
|
|
50
|
+
setLayout(el, 300, 0);
|
|
51
|
+
fireEvent.scroll(el);
|
|
52
|
+
act(() => void vi.advanceTimersByTime(60));
|
|
53
|
+
setLayout(el, 300, 300);
|
|
54
|
+
fireEvent.scroll(el);
|
|
55
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
56
|
+
expect(onSettle).toHaveBeenCalledOnce();
|
|
57
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not call onSettle again for the same index', () => {
|
|
61
|
+
const onSettle = vi.fn();
|
|
62
|
+
const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
|
|
63
|
+
const el = getByTestId('scroller');
|
|
64
|
+
setLayout(el, 300, 300);
|
|
65
|
+
fireEvent.scroll(el);
|
|
66
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
67
|
+
fireEvent.scroll(el);
|
|
68
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
69
|
+
expect(onSettle).toHaveBeenCalledOnce();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('clamps the settled index within [0, itemCount - 1]', () => {
|
|
73
|
+
const onSettle = vi.fn();
|
|
74
|
+
const { getByTestId } = render(<Harness itemCount={2} onSettle={onSettle} debounceMs={120} />);
|
|
75
|
+
const el = getByTestId('scroller');
|
|
76
|
+
setLayout(el, 300, 900); // rounds to index 3, clamps to itemCount - 1 = 1
|
|
77
|
+
fireEvent.scroll(el);
|
|
78
|
+
act(() => void vi.advanceTimersByTime(120));
|
|
79
|
+
expect(onSettle).toHaveBeenCalledWith(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('scrollToIndex scrolls the container to the target slide', () => {
|
|
83
|
+
let scrollToIndex: ((index: number) => void) | undefined;
|
|
84
|
+
const { getByTestId } = render(
|
|
85
|
+
<Harness itemCount={3} onScrollTo={(fn) => (scrollToIndex = fn)} />,
|
|
86
|
+
);
|
|
87
|
+
const el = getByTestId('scroller');
|
|
88
|
+
setLayout(el, 400, 0);
|
|
89
|
+
const spy = vi.spyOn(el, 'scrollTo');
|
|
90
|
+
scrollToIndex?.(2);
|
|
91
|
+
expect(spy).toHaveBeenCalledWith({ left: 800, behavior: 'smooth' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useSwipeReveal, type UseSwipeRevealOptions } from '../useSwipeReveal';
|
|
5
|
+
|
|
6
|
+
// jsdom doesn't implement pointer capture — stub it as a no-op so
|
|
7
|
+
// `e.currentTarget.setPointerCapture(...)` doesn't throw.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
if (!HTMLElement.prototype.setPointerCapture) {
|
|
10
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup);
|
|
15
|
+
|
|
16
|
+
function Harness(props: UseSwipeRevealOptions) {
|
|
17
|
+
const { rowRef, handlers } = useSwipeReveal(props);
|
|
18
|
+
return (
|
|
19
|
+
<div data-testid="row" ref={rowRef} {...handlers}>
|
|
20
|
+
row
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pointer(x: number, y: number) {
|
|
26
|
+
return { clientX: x, clientY: y, pointerId: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useSwipeReveal', () => {
|
|
30
|
+
it('calls onOpen when a horizontal drag crosses the halfway point', () => {
|
|
31
|
+
const onOpen = vi.fn();
|
|
32
|
+
const onClose = vi.fn();
|
|
33
|
+
const { getByTestId } = render(
|
|
34
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
35
|
+
);
|
|
36
|
+
const el = getByTestId('row');
|
|
37
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
38
|
+
fireEvent.pointerMove(el, pointer(-60, 0));
|
|
39
|
+
fireEvent.pointerUp(el, pointer(-60, 0));
|
|
40
|
+
expect(onOpen).toHaveBeenCalledOnce();
|
|
41
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('calls onClose when the drag does not cross the halfway point', () => {
|
|
45
|
+
const onOpen = vi.fn();
|
|
46
|
+
const onClose = vi.fn();
|
|
47
|
+
const { getByTestId } = render(
|
|
48
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
49
|
+
);
|
|
50
|
+
const el = getByTestId('row');
|
|
51
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
52
|
+
fireEvent.pointerMove(el, pointer(-20, 0));
|
|
53
|
+
fireEvent.pointerUp(el, pointer(-20, 0));
|
|
54
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
55
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('locks to vertical and ignores a mostly-vertical drag', () => {
|
|
59
|
+
const onOpen = vi.fn();
|
|
60
|
+
const onClose = vi.fn();
|
|
61
|
+
const { getByTestId } = render(
|
|
62
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
63
|
+
);
|
|
64
|
+
const el = getByTestId('row');
|
|
65
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
66
|
+
fireEvent.pointerMove(el, pointer(2, 60));
|
|
67
|
+
fireEvent.pointerUp(el, pointer(2, 60));
|
|
68
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
69
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does nothing when disabled', () => {
|
|
73
|
+
const onOpen = vi.fn();
|
|
74
|
+
const onClose = vi.fn();
|
|
75
|
+
const { getByTestId } = render(
|
|
76
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} disabled />,
|
|
77
|
+
);
|
|
78
|
+
const el = getByTestId('row');
|
|
79
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
80
|
+
fireEvent.pointerMove(el, pointer(-60, 0));
|
|
81
|
+
fireEvent.pointerUp(el, pointer(-60, 0));
|
|
82
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
83
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('closes from an already-open state when dragged back past halfway', () => {
|
|
87
|
+
const onOpen = vi.fn();
|
|
88
|
+
const onClose = vi.fn();
|
|
89
|
+
const { getByTestId } = render(
|
|
90
|
+
<Harness revealWidth={100} open onOpen={onOpen} onClose={onClose} />,
|
|
91
|
+
);
|
|
92
|
+
const el = getByTestId('row');
|
|
93
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
94
|
+
fireEvent.pointerMove(el, pointer(60, 0));
|
|
95
|
+
fireEvent.pointerUp(el, pointer(60, 0));
|
|
96
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
97
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('ignores pointerup without a preceding pointerdown', () => {
|
|
101
|
+
const onOpen = vi.fn();
|
|
102
|
+
const onClose = vi.fn();
|
|
103
|
+
const { getByTestId } = render(
|
|
104
|
+
<Harness revealWidth={100} open={false} onOpen={onOpen} onClose={onClose} />,
|
|
105
|
+
);
|
|
106
|
+
fireEvent.pointerUp(getByTestId('row'), pointer(-60, 0));
|
|
107
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
108
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -2,5 +2,23 @@ export { useLongPress } from './useLongPress';
|
|
|
2
2
|
export type { UseLongPressOptions, LongPressHandlers } from './useLongPress';
|
|
3
3
|
export { useDoubleTapHandler, useSingleOrDoubleTap } from './useDoubleTap';
|
|
4
4
|
export { useIsMobile, MOBILE_BREAKPOINT_PX } from './useIsMobile';
|
|
5
|
+
export { useIsOffline } from './useIsOffline';
|
|
6
|
+
export { useOfflineTileState } from './useOfflineTileState';
|
|
7
|
+
export type { OfflineTileState } from './useOfflineTileState';
|
|
5
8
|
export { useCommitOnEnterOrBlur } from './useCommitOnEnterOrBlur';
|
|
6
9
|
export type { CommitOnEnterOrBlurHandlers } from './useCommitOnEnterOrBlur';
|
|
10
|
+
export { useSwipeReveal } from './useSwipeReveal';
|
|
11
|
+
export type {
|
|
12
|
+
UseSwipeRevealOptions,
|
|
13
|
+
UseSwipeRevealHandlers,
|
|
14
|
+
UseSwipeRevealResult,
|
|
15
|
+
} from './useSwipeReveal';
|
|
16
|
+
export { useSnapCarousel } from './useSnapCarousel';
|
|
17
|
+
export type { UseSnapCarouselOptions, UseSnapCarouselResult } from './useSnapCarousel';
|
|
18
|
+
export { useResponsiveLayout } from './useResponsiveLayout';
|
|
19
|
+
export type { UseResponsiveLayoutOptions, UseResponsiveLayoutResult } from './useResponsiveLayout';
|
|
20
|
+
export { useCarouselRouteSync } from './useCarouselRouteSync';
|
|
21
|
+
export type {
|
|
22
|
+
UseCarouselRouteSyncOptions,
|
|
23
|
+
UseCarouselRouteSyncResult,
|
|
24
|
+
} from './useCarouselRouteSync';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseCarouselRouteSyncOptions {
|
|
6
|
+
/** Maps the current pathname to a slide index — the caller's own routing
|
|
7
|
+
* logic (e.g. special-casing a virtual "starred" slide), unchanged from
|
|
8
|
+
* whatever a plugin already has today. This hook has no idea what a
|
|
9
|
+
* plugin's routes look like, by design — it never imports a router. */
|
|
10
|
+
indexForPathname: (pathname: string) => number;
|
|
11
|
+
/** Inverse mapping — given a settled slide index, the path to navigate to. */
|
|
12
|
+
pathForIndex: (index: number) => string;
|
|
13
|
+
/** The current pathname. The caller reads this via their own router (e.g.
|
|
14
|
+
* `usePathname()` from `next/navigation`) and passes it in every render —
|
|
15
|
+
* this package stays framework-generic and has zero runtime dependencies. */
|
|
16
|
+
pathname: string;
|
|
17
|
+
/** Called when this hook wants to navigate — typically
|
|
18
|
+
* `(path) => router.replace(path, { scroll: false })`. */
|
|
19
|
+
onNavigate: (path: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseCarouselRouteSyncResult {
|
|
23
|
+
/** Feed directly into SwipableMobileCarousel's `activeIndex` prop. */
|
|
24
|
+
activeIndex: number;
|
|
25
|
+
/** Feed directly into SwipableMobileCarousel's `onSettle` prop. */
|
|
26
|
+
onSettle: (index: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* useCarouselRouteSync — centralizes the pathname↔slide-index mapping and the
|
|
31
|
+
* "was this pathname change our own settle, or an external navigation"
|
|
32
|
+
* distinction that carousel-driving plugins otherwise hand-roll per plugin
|
|
33
|
+
* (a tapped `<Link>`, browser back/forward, vs. this hook's own
|
|
34
|
+
* `router.replace` after a swipe settles). Router-agnostic on purpose: the
|
|
35
|
+
* caller owns the actual `usePathname()`/`router.replace()` calls and only
|
|
36
|
+
* hands this hook plain values/callbacks.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately does not own neighbor-prefetch (SwipableMobileCarousel's
|
|
39
|
+
* `prefetchDistance`/mount-window covers that) or scroll mechanics (the
|
|
40
|
+
* carousel component reacts to `activeIndex` changes itself) — this hook is
|
|
41
|
+
* pathname↔index mapping only, keeping routing glue decoupled from
|
|
42
|
+
* data-fetching and DOM scrolling.
|
|
43
|
+
*/
|
|
44
|
+
export function useCarouselRouteSync({
|
|
45
|
+
indexForPathname,
|
|
46
|
+
pathForIndex,
|
|
47
|
+
pathname,
|
|
48
|
+
onNavigate,
|
|
49
|
+
}: UseCarouselRouteSyncOptions): UseCarouselRouteSyncResult {
|
|
50
|
+
// Kept in refs and refreshed every render (not effect deps) so an inline
|
|
51
|
+
// arrow function passed by the caller each render doesn't retrigger the
|
|
52
|
+
// pathname-sync effect below — only an actual pathname change should.
|
|
53
|
+
const indexForPathnameRef = useRef(indexForPathname);
|
|
54
|
+
indexForPathnameRef.current = indexForPathname;
|
|
55
|
+
const pathForIndexRef = useRef(pathForIndex);
|
|
56
|
+
pathForIndexRef.current = pathForIndex;
|
|
57
|
+
const onNavigateRef = useRef(onNavigate);
|
|
58
|
+
onNavigateRef.current = onNavigate;
|
|
59
|
+
|
|
60
|
+
const [activeIndex, setActiveIndex] = useState(() => indexForPathname(pathname));
|
|
61
|
+
// Set synchronously inside onSettle, right before this hook's own
|
|
62
|
+
// navigation call — lets the pathname-sync effect below tell "this
|
|
63
|
+
// pathname change is our own settle, already reflected in activeIndex"
|
|
64
|
+
// apart from a genuinely external navigation.
|
|
65
|
+
const isInternalNavRef = useRef(false);
|
|
66
|
+
const didMountRef = useRef(false);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!didMountRef.current) {
|
|
70
|
+
// The very first render's activeIndex already reflects pathname via
|
|
71
|
+
// the lazy useState initializer above — nothing to resync on mount.
|
|
72
|
+
didMountRef.current = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (isInternalNavRef.current) {
|
|
76
|
+
isInternalNavRef.current = false;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const newIndex = indexForPathnameRef.current(pathname);
|
|
80
|
+
setActiveIndex((current) => (newIndex === current ? current : newIndex));
|
|
81
|
+
// Only re-run when pathname itself changes — indexForPathname is read
|
|
82
|
+
// through a ref precisely so its identity doesn't matter here.
|
|
83
|
+
}, [pathname]);
|
|
84
|
+
|
|
85
|
+
function onSettle(index: number) {
|
|
86
|
+
isInternalNavRef.current = true;
|
|
87
|
+
setActiveIndex(index);
|
|
88
|
+
onNavigateRef.current(pathForIndexRef.current(index));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { activeIndex, onSettle };
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useIsOffline — whether the browser currently has no network connection.
|
|
7
|
+
*
|
|
8
|
+
* SSR-safe: always initialises to `false` (online) so the server-rendered HTML
|
|
9
|
+
* matches the first client render, then reads the real `navigator.onLine`
|
|
10
|
+
* value in `useEffect`. This picks up the case where the page loaded already
|
|
11
|
+
* offline (e.g. served from the service-worker cache) at the cost of an
|
|
12
|
+
* imperceptible one-frame delay before the offline state appears — the same
|
|
13
|
+
* trade-off `OfflineBanner` makes, which this hook was extracted from so every
|
|
14
|
+
* consumer shares one connectivity source instead of re-deriving it slightly
|
|
15
|
+
* differently (research 0012, epic task 2.32).
|
|
16
|
+
*
|
|
17
|
+
* Reflects only `navigator.onLine` — true whenever the OS reports a network
|
|
18
|
+
* interface is up, even without real upstream connectivity (e.g. Wi-Fi with no
|
|
19
|
+
* internet). That is a known limitation of the browser API itself, not of this
|
|
20
|
+
* hook; there is currently no better cross-browser signal available.
|
|
21
|
+
*/
|
|
22
|
+
export function useIsOffline(): boolean {
|
|
23
|
+
const [isOffline, setIsOffline] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setIsOffline(!navigator.onLine);
|
|
27
|
+
|
|
28
|
+
const handleOffline = () => setIsOffline(true);
|
|
29
|
+
const handleOnline = () => setIsOffline(false);
|
|
30
|
+
|
|
31
|
+
window.addEventListener('offline', handleOffline);
|
|
32
|
+
window.addEventListener('online', handleOnline);
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
window.removeEventListener('offline', handleOffline);
|
|
36
|
+
window.removeEventListener('online', handleOnline);
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return isOffline;
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useIsOffline } from './useIsOffline';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The two reasons an app tile can be non-interactive, kept structurally
|
|
7
|
+
* distinct because they have different causes and must never read as the same
|
|
8
|
+
* thing to a user (research 0012, epic task 2.33):
|
|
9
|
+
*
|
|
10
|
+
* - `'connectivity-dimmed'` — a no-offline-tier app, dimmed only while the
|
|
11
|
+
* device is **actually offline right now**. Reactive and temporary; clears
|
|
12
|
+
* the instant connectivity returns.
|
|
13
|
+
* - `'capability-restricted'` — a `device-only` app on a surface without the
|
|
14
|
+
* durable, encrypted, device-auth-gated store it needs. Static and
|
|
15
|
+
* unrelated to connectivity — it stays restricted even when fully online,
|
|
16
|
+
* and must never say "offline" to a user who plainly isn't.
|
|
17
|
+
*/
|
|
18
|
+
export type OfflineTileState = 'connectivity-dimmed' | 'capability-restricted' | null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which of the two states, if any, applies to an app tile given its declared
|
|
22
|
+
* offline tier.
|
|
23
|
+
*
|
|
24
|
+
* `deviceOnlyAvailable` is the caller's own
|
|
25
|
+
* `isDeviceOnlyTierAvailable()` result (from
|
|
26
|
+
* `@sovereignfs/sdk/device-client`) — deliberately taken as a plain boolean
|
|
27
|
+
* parameter rather than computed inside this hook, so `@sovereignfs/ui` never
|
|
28
|
+
* depends on `@sovereignfs/sdk` (this package must stay usable standalone,
|
|
29
|
+
* outside the plugin runtime — RFC 0073).
|
|
30
|
+
*
|
|
31
|
+
* An `offline-first` app is never restricted by either state: that tier's
|
|
32
|
+
* whole point is working with no network, so it stays fully interactive both
|
|
33
|
+
* online and offline. A `device-only` app that *is* available behaves the
|
|
34
|
+
* same way — the restriction is about availability, not connectivity.
|
|
35
|
+
*/
|
|
36
|
+
export function useOfflineTileState(
|
|
37
|
+
offline: 'offline-first' | 'device-only' | undefined,
|
|
38
|
+
deviceOnlyAvailable: boolean,
|
|
39
|
+
): OfflineTileState {
|
|
40
|
+
const isOffline = useIsOffline();
|
|
41
|
+
|
|
42
|
+
if (offline === 'device-only' && !deviceOnlyAvailable) return 'capability-restricted';
|
|
43
|
+
if (offline === undefined && isOffline) return 'connectivity-dimmed';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useIsMobile } from './useIsMobile';
|
|
4
|
+
|
|
5
|
+
export interface UseResponsiveLayoutOptions<TWeb, TMobile> {
|
|
6
|
+
web: TWeb;
|
|
7
|
+
mobile: TMobile;
|
|
8
|
+
breakpointPx?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseResponsiveLayoutResult<TWeb, TMobile> {
|
|
12
|
+
isMobile: boolean;
|
|
13
|
+
value: TWeb | TMobile;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* useResponsiveLayout — picks between a `web` and `mobile` value for the
|
|
18
|
+
* current viewport, built on `useIsMobile` (same breakpoint/SSR-safe-default
|
|
19
|
+
* behavior, not reimplemented here). Generic over the value type so it works
|
|
20
|
+
* for JSX (the common case — forking an entire component tree, not just a
|
|
21
|
+
* class name) or any other per-breakpoint value a plugin wants to pick
|
|
22
|
+
* without repeating the isMobile-ternary boilerplate every call site.
|
|
23
|
+
*
|
|
24
|
+
* This formalizes the `if (isMobile) return <mobile/>; return <web/>;` fork
|
|
25
|
+
* plugins have hand-rolled per-plugin (e.g. a `MobileAwareShell` component
|
|
26
|
+
* that swaps an entire routed page tree for a client-only mobile one) —
|
|
27
|
+
* see `ResponsiveSurface` for the equivalent as a plain JSX component.
|
|
28
|
+
*/
|
|
29
|
+
export function useResponsiveLayout<TWeb, TMobile>({
|
|
30
|
+
web,
|
|
31
|
+
mobile,
|
|
32
|
+
breakpointPx,
|
|
33
|
+
}: UseResponsiveLayoutOptions<TWeb, TMobile>): UseResponsiveLayoutResult<TWeb, TMobile> {
|
|
34
|
+
const isMobile = useIsMobile(breakpointPx);
|
|
35
|
+
return { isMobile, value: isMobile ? mobile : web };
|
|
36
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import type { RefObject } from 'react';
|
|
5
|
+
|
|
6
|
+
export interface UseSnapCarouselOptions {
|
|
7
|
+
/** Total number of slides — settle detection clamps to this range. */
|
|
8
|
+
itemCount: number;
|
|
9
|
+
/** Called when the user's scroll gesture settles on a different slide
|
|
10
|
+
* index than the last one reported. */
|
|
11
|
+
onSettle?: (index: number) => void;
|
|
12
|
+
/** Debounce delay (ms) after the last `scroll` event before checking where
|
|
13
|
+
* the container settled. Default 120 — matches the value this hook was
|
|
14
|
+
* extracted from. Debounced rather than keyed to the `scrollend` event,
|
|
15
|
+
* which pre-17.4 iOS Safari/WKWebView doesn't support. */
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseSnapCarouselResult {
|
|
20
|
+
/** Attach to the `scroll-snap-type: x` scroll container. */
|
|
21
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
22
|
+
/** Imperatively scrolls the container to a slide index. */
|
|
23
|
+
scrollToIndex: (index: number, behavior?: ScrollBehavior) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* useSnapCarousel — debounced "settled slide" detection over a native
|
|
28
|
+
* `scroll-snap-type: x` container, extracted from `sovereign-tasks`'s
|
|
29
|
+
* `MobileTasksCarousel` and independently reimplemented in
|
|
30
|
+
* `sovereign-shopper`'s `MobileShopperCarousel` (RFC 0079, epic task 9.20).
|
|
31
|
+
* Gives swipe-between-slides physics for free via native scroll-snap, with
|
|
32
|
+
* this hook only handling "which slide did we land on."
|
|
33
|
+
*/
|
|
34
|
+
export function useSnapCarousel({
|
|
35
|
+
itemCount,
|
|
36
|
+
onSettle,
|
|
37
|
+
debounceMs = 120,
|
|
38
|
+
}: UseSnapCarouselOptions): UseSnapCarouselResult {
|
|
39
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const lastIndexRef = useRef<number | null>(null);
|
|
42
|
+
// Kept in a ref so the scroll listener always calls the latest callback
|
|
43
|
+
// without needing to be torn down and re-attached on every render.
|
|
44
|
+
const onSettleRef = useRef(onSettle);
|
|
45
|
+
onSettleRef.current = onSettle;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const el = scrollRef.current;
|
|
49
|
+
if (!el) return;
|
|
50
|
+
function handleScroll() {
|
|
51
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
52
|
+
timerRef.current = setTimeout(() => {
|
|
53
|
+
const current = scrollRef.current;
|
|
54
|
+
if (!current) return;
|
|
55
|
+
const width = current.clientWidth;
|
|
56
|
+
if (!width) return;
|
|
57
|
+
const rawIndex = Math.round(current.scrollLeft / width);
|
|
58
|
+
const newIndex = Math.max(0, Math.min(itemCount - 1, rawIndex));
|
|
59
|
+
if (newIndex === lastIndexRef.current) return;
|
|
60
|
+
lastIndexRef.current = newIndex;
|
|
61
|
+
onSettleRef.current?.(newIndex);
|
|
62
|
+
}, debounceMs);
|
|
63
|
+
}
|
|
64
|
+
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
65
|
+
return () => {
|
|
66
|
+
el.removeEventListener('scroll', handleScroll);
|
|
67
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
68
|
+
};
|
|
69
|
+
}, [itemCount, debounceMs]);
|
|
70
|
+
|
|
71
|
+
function scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth') {
|
|
72
|
+
const el = scrollRef.current;
|
|
73
|
+
if (!el) return;
|
|
74
|
+
lastIndexRef.current = index;
|
|
75
|
+
el.scrollTo({ left: index * el.clientWidth, behavior });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { scrollRef, scrollToIndex };
|
|
79
|
+
}
|