@sovereignfs/ui 0.45.0 → 0.83.1
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 +1 -1
- package/dist/Alert.module.css +1 -1
- package/dist/AppsLauncher.module.css +96 -0
- package/dist/Avatar.module.css +1 -1
- package/dist/Badge.module.css +30 -7
- package/dist/BalanceChip.module.css +1 -1
- package/dist/Button.module.css +18 -2
- package/dist/Card.module.css +1 -1
- package/dist/CardTile.module.css +181 -0
- package/dist/CodeTextarea.module.css +1 -1
- package/dist/Collapsible.module.css +1 -1
- package/dist/ColorPicker.module.css +132 -0
- package/dist/Combobox.module.css +2 -2
- package/dist/Command.module.css +1 -1
- package/dist/ConfirmDialog.module.css +2 -2
- package/dist/ConsentPrompt.module.css +21 -0
- package/dist/DatePicker.module.css +1 -1
- package/dist/Dialog.module.css +130 -21
- package/dist/Drawer.module.css +36 -7
- package/dist/Header.module.css +62 -0
- package/dist/HeaderFooterLayout.module.css +21 -0
- package/dist/IconPicker.module.css +1 -1
- package/dist/Input.module.css +1 -1
- package/dist/Kbd.module.css +1 -1
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +73 -0
- package/dist/Menu.module.css +1 -0
- package/dist/Message.module.css +1 -1
- package/dist/MessageScroller.module.css +12 -1
- package/dist/MobileAppsDrawer.module.css +82 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +76 -0
- package/dist/NavList.module.css +130 -0
- package/dist/NavTabs.module.css +1 -1
- package/dist/NotificationsPanel.module.css +258 -0
- package/dist/OverlayHeader.module.css +1 -1
- package/dist/PageContainer.module.css +82 -0
- package/dist/PageHeader.module.css +54 -0
- package/dist/PageLayout.module.css +51 -0
- package/dist/Popover.module.css +1 -1
- package/dist/QuantityStepper.module.css +1 -1
- package/dist/Resizable.module.css +3 -3
- package/dist/RootLayout.module.css +12 -0
- package/dist/SegmentedControl.module.css +1 -1
- package/dist/Select.module.css +1 -1
- package/dist/Sheet.module.css +4 -1
- package/dist/SplitPane.module.css +4 -4
- package/dist/StatusBadge.module.css +1 -1
- package/dist/StepStrip.module.css +53 -0
- package/dist/SuggestionInput.module.css +2 -2
- package/dist/SwipableMobileCarousel.module.css +64 -0
- package/dist/SwipableMobileCarouselDots.module.css +64 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/SwipeStack.module.css +101 -0
- package/dist/SystemBanner.module.css +10 -0
- package/dist/Table.module.css +2 -2
- package/dist/Tabs.module.css +1 -1
- package/dist/TagInput.module.css +2 -2
- package/dist/Textarea.module.css +9 -1
- package/dist/ThreeColumnLayout.module.css +35 -0
- package/dist/Toast.module.css +62 -3
- package/dist/Tooltip.module.css +1 -1
- package/dist/UserMenu.module.css +90 -0
- package/dist/index.d.ts +1680 -30
- package/dist/index.js +4618 -1443
- package/dist/tokens/primitives.css +36 -0
- package/dist/tokens/semantic.css +32 -0
- package/dist/tokens/theme-presets.css +33 -0
- package/package.json +32 -12
- package/src/__tests__/package-json.test.ts +8 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/Alert/Alert.module.css +1 -1
- package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
- package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
- package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
- package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
- package/src/components/Avatar/Avatar.module.css +1 -1
- package/src/components/Badge/Badge.module.css +30 -7
- package/src/components/Badge/Badge.stories.tsx +16 -4
- package/src/components/Badge/Badge.tsx +8 -11
- package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
- package/src/components/BalanceChip/BalanceChip.module.css +1 -1
- package/src/components/Button/Button.module.css +18 -2
- package/src/components/Card/Card.module.css +1 -1
- package/src/components/CardTile/CardTile.module.css +181 -0
- package/src/components/CardTile/CardTile.stories.tsx +86 -0
- package/src/components/CardTile/CardTile.tsx +128 -0
- package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/ColorPicker/ColorPicker.module.css +132 -0
- package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
- package/src/components/ColorPicker/ColorPicker.tsx +157 -0
- package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
- package/src/components/Combobox/Combobox.module.css +2 -2
- package/src/components/Command/Command.module.css +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
- package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
- package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
- package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
- package/src/components/ContextMenu/ContextMenu.tsx +1 -1
- package/src/components/DatePicker/DatePicker.module.css +1 -1
- 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 +130 -21
- package/src/components/Dialog/Dialog.stories.tsx +179 -2
- package/src/components/Dialog/Dialog.tsx +137 -94
- package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
- package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
- package/src/components/Drawer/Drawer.module.css +36 -7
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +27 -66
- package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
- package/src/components/GripIcon/GripIcon.tsx +27 -0
- package/src/components/Header/Header.module.css +62 -0
- package/src/components/Header/Header.stories.tsx +452 -0
- package/src/components/Header/Header.tsx +102 -0
- package/src/components/Header/__tests__/Header.test.tsx +104 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
- package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
- package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
- package/src/components/Icon/icons/bold.tsx +20 -0
- package/src/components/Icon/icons/calendar.tsx +4 -4
- package/src/components/Icon/icons/carrot.tsx +3 -3
- package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
- package/src/components/Icon/icons/code.tsx +21 -0
- package/src/components/Icon/icons/copy.tsx +21 -0
- package/src/components/Icon/icons/credit-card.tsx +21 -0
- package/src/components/Icon/icons/database.tsx +22 -0
- package/src/components/Icon/icons/download.tsx +22 -0
- package/src/components/Icon/icons/file-text.tsx +24 -0
- package/src/components/Icon/icons/folder-closed.tsx +21 -0
- package/src/components/Icon/icons/folder-open.tsx +20 -0
- package/src/components/Icon/icons/folder.tsx +20 -0
- package/src/components/Icon/icons/folders.tsx +21 -0
- package/src/components/Icon/icons/hat-glasses.tsx +24 -0
- package/src/components/Icon/icons/history.tsx +22 -0
- package/src/components/Icon/icons/inbox.tsx +21 -0
- package/src/components/Icon/icons/index.ts +169 -1
- package/src/components/Icon/icons/italic.tsx +22 -0
- package/src/components/Icon/icons/layers.tsx +22 -0
- package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
- package/src/components/Icon/icons/layout-grid.tsx +23 -0
- package/src/components/Icon/icons/link.tsx +21 -0
- package/src/components/Icon/icons/list-ordered.tsx +25 -0
- package/src/components/Icon/icons/list.tsx +25 -0
- package/src/components/Icon/icons/luggage.tsx +24 -0
- package/src/components/Icon/icons/map-pin.tsx +21 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/minus.tsx +20 -0
- package/src/components/Icon/icons/paint-bucket.tsx +23 -0
- package/src/components/Icon/icons/panel-left.tsx +21 -0
- package/src/components/Icon/icons/pin.tsx +21 -0
- package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
- package/src/components/Icon/icons/refresh-cw.tsx +23 -0
- package/src/components/Icon/icons/rotate-cw.tsx +21 -0
- package/src/components/Icon/icons/route.tsx +22 -0
- package/src/components/Icon/icons/send.tsx +21 -0
- package/src/components/Icon/icons/share-2.tsx +24 -0
- package/src/components/Icon/icons/sheet.tsx +24 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Icon/icons/square-kanban.tsx +23 -0
- package/src/components/Icon/icons/table.tsx +23 -0
- package/src/components/Icon/icons/user-round-plus.tsx +23 -0
- package/src/components/Icon/icons/users.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Input/Input.tsx +8 -2
- package/src/components/Kbd/Kbd.module.css +1 -1
- 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 +73 -0
- package/src/components/Markdown/Markdown.stories.tsx +40 -0
- package/src/components/Markdown/Markdown.tsx +305 -0
- package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
- package/src/components/Menu/Menu.module.css +1 -0
- package/src/components/Menu/Menu.tsx +72 -22
- package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
- package/src/components/Menubar/Menubar.tsx +1 -1
- package/src/components/Message/Message.module.css +1 -1
- package/src/components/MessageScroller/MessageScroller.module.css +12 -1
- package/src/components/MessageScroller/MessageScroller.tsx +12 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -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 +122 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
- package/src/components/MobileHeader/MobileHeader.module.css +76 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +90 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
- package/src/components/NavList/NavList.module.css +130 -0
- package/src/components/NavList/NavList.tsx +136 -0
- package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
- package/src/components/NavTabs/NavTabs.module.css +1 -1
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
- package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
- package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
- package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
- package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
- package/src/components/PageContainer/PageContainer.module.css +82 -0
- package/src/components/PageContainer/PageContainer.tsx +64 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
- package/src/components/PageHeader/PageHeader.module.css +54 -0
- package/src/components/PageHeader/PageHeader.tsx +157 -2
- package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
- package/src/components/PageLayout/PageLayout.module.css +51 -0
- package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
- package/src/components/PageLayout/PageLayout.tsx +51 -0
- package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
- package/src/components/Popover/Popover.module.css +1 -1
- package/src/components/Popover/Popover.tsx +86 -4
- package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
- package/src/components/Resizable/Resizable.module.css +3 -3
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/RootLayout/RootLayout.module.css +12 -0
- package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
- package/src/components/RootLayout/RootLayout.tsx +185 -0
- package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
- package/src/components/Select/Select.module.css +1 -1
- package/src/components/Sheet/Sheet.module.css +4 -1
- package/src/components/Sheet/Sheet.tsx +14 -56
- package/src/components/SplitPane/SplitPane.module.css +4 -4
- package/src/components/StatusBadge/StatusBadge.module.css +1 -1
- package/src/components/StepStrip/StepStrip.module.css +53 -0
- package/src/components/StepStrip/StepStrip.tsx +74 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -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 +214 -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 +64 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/SwipeStack/SwipeStack.module.css +101 -0
- package/src/components/SwipeStack/SwipeStack.tsx +199 -0
- package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
- package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
- package/src/components/SystemBanner/SystemBanner.module.css +10 -0
- package/src/components/Table/Table.module.css +2 -2
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/TagInput/TagInput.module.css +2 -2
- package/src/components/Textarea/Textarea.module.css +9 -1
- package/src/components/Textarea/Textarea.tsx +28 -5
- package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
- package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
- package/src/components/Toast/Toast.module.css +62 -3
- package/src/components/Toast/Toast.stories.tsx +1 -1
- package/src/components/Tooltip/Tooltip.module.css +1 -1
- package/src/components/UserMenu/UserMenu.module.css +90 -0
- package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
- package/src/components/UserMenu/UserMenu.tsx +132 -0
- package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -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__/usePublishShellChromeHeight.test.tsx +118 -0
- package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
- package/src/hooks/index.ts +27 -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/usePublishShellChromeHeight.ts +96 -0
- package/src/hooks/useReorderSensors.ts +88 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +284 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/hooks/useSwipeStack.ts +235 -0
- package/src/index.ts +124 -3
- package/src/overlay-shell.ts +159 -0
- package/src/stories/DesignSystemOverview.stories.tsx +933 -6
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/GripIcon.stories.tsx +29 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/LayoutSystem.stories.tsx +639 -0
- package/src/stories/MenuEntries.stories.tsx +86 -0
- package/src/stories/MobilePatterns.stories.tsx +247 -2
- package/src/stories/NavList.stories.tsx +147 -0
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +82 -0
- package/src/stories/PageHeader.stories.tsx +65 -1
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/StepStrip.stories.tsx +76 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
- package/src/stories/SwipeStack.stories.tsx +200 -0
- package/src/stories/Textarea.stories.tsx +12 -0
- package/src/stories/TokenGallery.stories.tsx +115 -0
- package/src/tokens/primitives.css +36 -0
- package/src/tokens/semantic.css +32 -0
- package/src/tokens/theme-presets.css +33 -0
- package/src/tokens/theme-presets.ts +72 -0
|
@@ -0,0 +1,155 @@
|
|
|
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 { useSwipeStack, type SwipeDirection, type UseSwipeStackOptions } from '../useSwipeStack';
|
|
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: UseSwipeStackOptions & { triggerDirection?: SwipeDirection }) {
|
|
17
|
+
const { triggerDirection, ...options } = props;
|
|
18
|
+
const { cardRef, wrapRef, handlers, touchAction, triggerCommit } = useSwipeStack(options);
|
|
19
|
+
return (
|
|
20
|
+
<div data-testid="wrap" ref={wrapRef}>
|
|
21
|
+
<div data-testid="card" ref={cardRef} data-touch-action={touchAction} {...handlers}>
|
|
22
|
+
card
|
|
23
|
+
</div>
|
|
24
|
+
{triggerDirection && (
|
|
25
|
+
<button data-testid="trigger" onClick={() => triggerCommit(triggerDirection)}>
|
|
26
|
+
trigger
|
|
27
|
+
</button>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pointer(x: number, y: number) {
|
|
34
|
+
return { clientX: x, clientY: y, pointerId: 1 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function drag(el: HTMLElement, dx: number, dy: number) {
|
|
38
|
+
fireEvent.pointerDown(el, pointer(0, 0));
|
|
39
|
+
fireEvent.pointerMove(el, pointer(dx, dy));
|
|
40
|
+
fireEvent.pointerUp(el, pointer(dx, dy));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('useSwipeStack', () => {
|
|
44
|
+
it('commits when a drag crosses threshold in a live direction', () => {
|
|
45
|
+
const onCommit = vi.fn();
|
|
46
|
+
const { getByTestId } = render(
|
|
47
|
+
<Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
|
|
48
|
+
);
|
|
49
|
+
drag(getByTestId('card'), 0, -120);
|
|
50
|
+
expect(onCommit).toHaveBeenCalledWith('up');
|
|
51
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('springs back without committing when under threshold', () => {
|
|
55
|
+
const onCommit = vi.fn();
|
|
56
|
+
const { getByTestId } = render(
|
|
57
|
+
<Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
|
|
58
|
+
);
|
|
59
|
+
drag(getByTestId('card'), 0, -40);
|
|
60
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not commit within the deadzone even if threshold is set very low', () => {
|
|
64
|
+
const onCommit = vi.fn();
|
|
65
|
+
const { getByTestId } = render(
|
|
66
|
+
<Harness directions={['up']} threshold={2} onCommit={onCommit} />,
|
|
67
|
+
);
|
|
68
|
+
drag(getByTestId('card'), 0, -5);
|
|
69
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('treats a non-configured direction as a wall — no commit even past what would be threshold distance', () => {
|
|
73
|
+
const onCommit = vi.fn();
|
|
74
|
+
const { getByTestId } = render(
|
|
75
|
+
<Harness directions={['up', 'down']} threshold={100} onCommit={onCommit} />,
|
|
76
|
+
);
|
|
77
|
+
drag(getByTestId('card'), 150, 0);
|
|
78
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does nothing when disabled', () => {
|
|
82
|
+
const onCommit = vi.fn();
|
|
83
|
+
const { getByTestId } = render(
|
|
84
|
+
<Harness
|
|
85
|
+
directions={['up', 'down', 'left', 'right']}
|
|
86
|
+
threshold={100}
|
|
87
|
+
onCommit={onCommit}
|
|
88
|
+
disabled
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
drag(getByTestId('card'), 0, -150);
|
|
92
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores pointerup without a preceding pointerdown', () => {
|
|
96
|
+
const onCommit = vi.fn();
|
|
97
|
+
const { getByTestId } = render(
|
|
98
|
+
<Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
|
|
99
|
+
);
|
|
100
|
+
fireEvent.pointerUp(getByTestId('card'), pointer(0, -150));
|
|
101
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('triggerCommit commits a live direction without a drag', () => {
|
|
105
|
+
const onCommit = vi.fn();
|
|
106
|
+
const { getByTestId } = render(
|
|
107
|
+
<Harness directions={['up']} onCommit={onCommit} triggerDirection="up" />,
|
|
108
|
+
);
|
|
109
|
+
fireEvent.click(getByTestId('trigger'));
|
|
110
|
+
expect(onCommit).toHaveBeenCalledWith('up');
|
|
111
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('triggerCommit no-ops for a direction that is not live', () => {
|
|
115
|
+
const onCommit = vi.fn();
|
|
116
|
+
const { getByTestId } = render(
|
|
117
|
+
<Harness directions={['up']} onCommit={onCommit} triggerDirection="down" />,
|
|
118
|
+
);
|
|
119
|
+
fireEvent.click(getByTestId('trigger'));
|
|
120
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('triggerCommit does nothing when disabled', () => {
|
|
124
|
+
const onCommit = vi.fn();
|
|
125
|
+
const { getByTestId } = render(
|
|
126
|
+
<Harness directions={['up']} onCommit={onCommit} triggerDirection="up" disabled />,
|
|
127
|
+
);
|
|
128
|
+
fireEvent.click(getByTestId('trigger'));
|
|
129
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('touchAction', () => {
|
|
133
|
+
it('is none when both axes have a live direction', () => {
|
|
134
|
+
const { getByTestId } = render(
|
|
135
|
+
<Harness directions={['left', 'right', 'up', 'down']} onCommit={vi.fn()} />,
|
|
136
|
+
);
|
|
137
|
+
expect(getByTestId('card').dataset.touchAction).toBe('none');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('is pan-y when only horizontal directions are live', () => {
|
|
141
|
+
const { getByTestId } = render(<Harness directions={['left', 'right']} onCommit={vi.fn()} />);
|
|
142
|
+
expect(getByTestId('card').dataset.touchAction).toBe('pan-y');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('is pan-x when only vertical directions are live', () => {
|
|
146
|
+
const { getByTestId } = render(<Harness directions={['up', 'down']} onCommit={vi.fn()} />);
|
|
147
|
+
expect(getByTestId('card').dataset.touchAction).toBe('pan-x');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('is auto when no directions are live', () => {
|
|
151
|
+
const { getByTestId } = render(<Harness directions={[]} onCommit={vi.fn()} />);
|
|
152
|
+
expect(getByTestId('card').dataset.touchAction).toBe('auto');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -2,5 +2,32 @@ 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';
|
|
25
|
+
export { useReorderSensors, shouldHandleDndEvent } from './useReorderSensors';
|
|
26
|
+
export type { UseReorderSensorsOptions } from './useReorderSensors';
|
|
27
|
+
export { useSwipeStack, SWIPE_STACK_TRANSITION_MS } from './useSwipeStack';
|
|
28
|
+
export type {
|
|
29
|
+
SwipeDirection,
|
|
30
|
+
UseSwipeStackOptions,
|
|
31
|
+
UseSwipeStackHandlers,
|
|
32
|
+
UseSwipeStackResult,
|
|
33
|
+
} from './useSwipeStack';
|
|
@@ -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,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect } from 'react';
|
|
4
|
+
import type { RefObject } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal implementation detail of `MobileHeader`/`MobileFooter` — not part
|
|
8
|
+
* of the package's public surface (not re-exported from `hooks/index.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Measures `ref`'s own rendered height and publishes it as an inline
|
|
11
|
+
* `var(--sv-shell-header-height|--sv-shell-footer-height)` override on
|
|
12
|
+
* `#sv-app-shell` (the runtime shell's stable root id, `runtime/app/
|
|
13
|
+
* (platform)/layout.tsx`), removing it on unmount. This is what makes a
|
|
14
|
+
* *self-rendered* `MobileHeader`/`MobileFooter` — the sanctioned pattern for
|
|
15
|
+
* a plugin that opts out of the platform's own chrome via
|
|
16
|
+
* `shellConfig.mobileHeader`/`mobileFooter: false` (RFC 0075/0088) — work
|
|
17
|
+
* correctly with `Sheet`/`Drawer`/`Dialog`, which all size themselves against
|
|
18
|
+
* these same two variables so they stop above/below the chrome instead of
|
|
19
|
+
* sliding underneath it.
|
|
20
|
+
*
|
|
21
|
+
* Before this existed, only the *platform's own* header/footer had its
|
|
22
|
+
* height accounted for (a fixed CSS `calc()` in `shell.module.css`, driven by
|
|
23
|
+
* `data-mobile-header-hidden`/`data-mobile-footer-hidden`, which collapses
|
|
24
|
+
* the variable to `0px` whenever a plugin hides that chrome) — a plugin
|
|
25
|
+
* self-rendering its own via this same component instead had no path to tell
|
|
26
|
+
* the shell what its real height was, and silently inherited that `0px`.
|
|
27
|
+
* `Sheet`/`Drawer` then extended full-height, and since the header/footer's
|
|
28
|
+
* own `z-index` (101) beats an overlay's (100), the chrome visibly covered
|
|
29
|
+
* the overlay's last ~60px. First found and fixed ad hoc in `sovereign-tasks`
|
|
30
|
+
* (measuring in the plugin itself, `getBoundingClientRect()` in a
|
|
31
|
+
* `useLayoutEffect`); moved here so every consumer of these two components —
|
|
32
|
+
* present and future, in this repo or externally maintained — gets it for
|
|
33
|
+
* free, without having to know the mechanism exists. See
|
|
34
|
+
* `docs/architecture-rules.md` for the full rule.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately **not** `ResizeObserver`: in live testing (both this repo's
|
|
37
|
+
* own Chromium-based browser-preview tooling and a real WebKit iOS Simulator
|
|
38
|
+
* session) a freshly created `ResizeObserver` never fired its callback even
|
|
39
|
+
* once for an already-rendered, stably-sized, non-zero element — cause not
|
|
40
|
+
* fully root-caused. A synchronous `getBoundingClientRect()` read in
|
|
41
|
+
* `useLayoutEffect`, re-run on `window`'s `resize` event, worked immediately
|
|
42
|
+
* and reliably in both environments instead.
|
|
43
|
+
*
|
|
44
|
+
* No-ops safely wherever `#sv-app-shell` doesn't exist (Storybook, a unit
|
|
45
|
+
* test, or any host other than the platform's own runtime shell) — every CSS
|
|
46
|
+
* consumer reads these variables with a `60px` fallback, so an absent
|
|
47
|
+
* override is harmless.
|
|
48
|
+
*
|
|
49
|
+
* For `--sv-shell-header-height` specifically, this also mirrors the same
|
|
50
|
+
* measured height onto `--sv-dialog-inset-top` on `:root` (`document.
|
|
51
|
+
* documentElement`, not `#sv-app-shell`) — `Dialog`'s mobile `.scrim` is
|
|
52
|
+
* `position: fixed` and reads that variable for its own `top` offset, and
|
|
53
|
+
* per `globals.css`'s own comment, iOS Safari has a documented quirk where a
|
|
54
|
+
* `position: fixed` element inherits custom properties from `:root` rather
|
|
55
|
+
* than from its DOM ancestors, so a value set only on `#sv-app-shell` (an
|
|
56
|
+
* ancestor, not `:root`) silently never reaches it there. The platform's own
|
|
57
|
+
* header already keeps `--sv-dialog-inset-top` correct this way, via
|
|
58
|
+
* `ClientShell.tsx`'s own `syncViewport()` — but that function is part of
|
|
59
|
+
* `(platform)/layout.tsx` and never runs for a `shell: minimal` plugin, so a
|
|
60
|
+
* self-rendered `MobileHeader` (this hook's entire reason to exist) had no
|
|
61
|
+
* equivalent, and `--sv-dialog-inset-top` silently kept `globals.css`'s
|
|
62
|
+
* hardcoded platform-header-height fallback (60px) regardless of this
|
|
63
|
+
* header's real height. Found live in `sovereign-plugin-kanban`: its
|
|
64
|
+
* self-rendered header measures 69px, so its card-detail `Dialog` opened
|
|
65
|
+
* with a real, reproducible ~9px misalignment against that header's own
|
|
66
|
+
* bottom edge — confirmed via `getBoundingClientRect()` on both elements,
|
|
67
|
+
* not just visually. */
|
|
68
|
+
export function usePublishShellChromeHeight(
|
|
69
|
+
ref: RefObject<HTMLElement | null>,
|
|
70
|
+
cssVariable: '--sv-shell-header-height' | '--sv-shell-footer-height',
|
|
71
|
+
) {
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const el = ref.current;
|
|
74
|
+
if (!el) return;
|
|
75
|
+
|
|
76
|
+
function publish() {
|
|
77
|
+
const shell = document.getElementById('sv-app-shell');
|
|
78
|
+
if (!shell || !el) return;
|
|
79
|
+
const height = `${el.getBoundingClientRect().height}px`;
|
|
80
|
+
shell.style.setProperty(cssVariable, height);
|
|
81
|
+
if (cssVariable === '--sv-shell-header-height') {
|
|
82
|
+
document.documentElement.style.setProperty('--sv-dialog-inset-top', height);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
publish();
|
|
87
|
+
window.addEventListener('resize', publish);
|
|
88
|
+
return () => {
|
|
89
|
+
window.removeEventListener('resize', publish);
|
|
90
|
+
document.getElementById('sv-app-shell')?.style.removeProperty(cssVariable);
|
|
91
|
+
if (cssVariable === '--sv-shell-header-height') {
|
|
92
|
+
document.documentElement.style.removeProperty('--sv-dialog-inset-top');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}, [ref, cssVariable]);
|
|
96
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
KeyboardSensor,
|
|
5
|
+
MouseSensor as LibMouseSensor,
|
|
6
|
+
TouchSensor as LibTouchSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from '@dnd-kit/core';
|
|
10
|
+
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
11
|
+
import type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';
|
|
12
|
+
|
|
13
|
+
/** Plain pointer drag (desktop, handle-initiated). */
|
|
14
|
+
const DEFAULT_MOUSE_ACTIVATION_DISTANCE_PX = 8;
|
|
15
|
+
|
|
16
|
+
/** Long-press-to-lift on touch. `delay` is how long a still hold takes before
|
|
17
|
+
* the row lifts; `tolerance` is how far the finger may drift during that
|
|
18
|
+
* hold before it's treated as a scroll instead — a finger that moves further
|
|
19
|
+
* than this within the delay window cancels activation and the native
|
|
20
|
+
* vertical scroll wins. */
|
|
21
|
+
const DEFAULT_TOUCH_ACTIVATION_DELAY_MS = 300;
|
|
22
|
+
const DEFAULT_TOUCH_ACTIVATION_TOLERANCE_PX = 8;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True when a drag should be allowed to start from `target`. Refused when
|
|
26
|
+
* `target` sits inside an element marked `data-no-dnd` — a nested control
|
|
27
|
+
* (e.g. a visibility toggle) opts out so tapping it fires its own handler
|
|
28
|
+
* instead of lifting the row. Exported standalone so it's unit-testable
|
|
29
|
+
* without spinning up dnd-kit.
|
|
30
|
+
*/
|
|
31
|
+
export function shouldHandleDndEvent(target: EventTarget | null): boolean {
|
|
32
|
+
if (!(target instanceof Element)) return true;
|
|
33
|
+
return target.closest('[data-no-dnd]') === null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseReorderSensorsOptions {
|
|
37
|
+
mouseActivationDistancePx?: number;
|
|
38
|
+
touchActivationDelayMs?: number;
|
|
39
|
+
touchActivationTolerancePx?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// dnd-kit's documented pattern for scoping sensor activation to specific
|
|
43
|
+
// elements: subclass the built-in sensor and replace its static `activators`
|
|
44
|
+
// with a handler that inspects the originating event's target before
|
|
45
|
+
// deferring to the base activation logic (distance/delay/tolerance), which
|
|
46
|
+
// dnd-kit re-checks internally regardless of what this returns.
|
|
47
|
+
class MouseSensor extends LibMouseSensor {
|
|
48
|
+
static override activators = [
|
|
49
|
+
{
|
|
50
|
+
eventName: 'onMouseDown' as const,
|
|
51
|
+
handler: ({ nativeEvent: event }: ReactMouseEvent) => shouldHandleDndEvent(event.target),
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class TouchSensor extends LibTouchSensor {
|
|
57
|
+
static override activators = [
|
|
58
|
+
{
|
|
59
|
+
eventName: 'onTouchStart' as const,
|
|
60
|
+
handler: ({ nativeEvent: event }: ReactTouchEvent) => shouldHandleDndEvent(event.target),
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sensor set for a `data-no-dnd`-aware drag-reorder list — MouseSensor for
|
|
67
|
+
* desktop's handle-initiated drag, TouchSensor for mobile's long-press lift
|
|
68
|
+
* (fixes reordering being unusable on iOS PWA/Safari, which never implements
|
|
69
|
+
* the native HTML5 Drag-and-Drop API for touch input), KeyboardSensor
|
|
70
|
+
* unchanged. A bare call keeps the historical 8px/300ms/8px defaults.
|
|
71
|
+
*/
|
|
72
|
+
export function useReorderSensors(options: UseReorderSensorsOptions = {}) {
|
|
73
|
+
const {
|
|
74
|
+
mouseActivationDistancePx = DEFAULT_MOUSE_ACTIVATION_DISTANCE_PX,
|
|
75
|
+
touchActivationDelayMs = DEFAULT_TOUCH_ACTIVATION_DELAY_MS,
|
|
76
|
+
touchActivationTolerancePx = DEFAULT_TOUCH_ACTIVATION_TOLERANCE_PX,
|
|
77
|
+
} = options;
|
|
78
|
+
return useSensors(
|
|
79
|
+
useSensor(MouseSensor, { activationConstraint: { distance: mouseActivationDistancePx } }),
|
|
80
|
+
useSensor(TouchSensor, {
|
|
81
|
+
activationConstraint: {
|
|
82
|
+
delay: touchActivationDelayMs,
|
|
83
|
+
tolerance: touchActivationTolerancePx,
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|