@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,169 @@
|
|
|
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 { SwipeStack } from '../SwipeStack';
|
|
5
|
+
import { SwipeStackCard } from '../SwipeStackCard';
|
|
6
|
+
import { SWIPE_STACK_TRANSITION_MS } from '../../../hooks/useSwipeStack';
|
|
7
|
+
|
|
8
|
+
// jsdom doesn't implement pointer capture — stub it as a no-op so
|
|
9
|
+
// `e.currentTarget.setPointerCapture(...)` doesn't throw.
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
if (!HTMLElement.prototype.setPointerCapture) {
|
|
12
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
cleanup();
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function pointer(x: number, y: number) {
|
|
23
|
+
return { clientX: x, clientY: y, pointerId: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderStack(onSwipe = vi.fn()) {
|
|
27
|
+
const utils = render(
|
|
28
|
+
<SwipeStack
|
|
29
|
+
aria-label="Today's agenda"
|
|
30
|
+
directions={{
|
|
31
|
+
up: { label: 'Done', icon: 'check' },
|
|
32
|
+
down: { label: 'Defer', icon: 'history' },
|
|
33
|
+
}}
|
|
34
|
+
onSwipe={onSwipe}
|
|
35
|
+
>
|
|
36
|
+
<SwipeStackCard cardId="a">Card A</SwipeStackCard>
|
|
37
|
+
<SwipeStackCard cardId="b">Card B</SwipeStackCard>
|
|
38
|
+
<SwipeStackCard cardId="c">Card C</SwipeStackCard>
|
|
39
|
+
</SwipeStack>,
|
|
40
|
+
);
|
|
41
|
+
return { ...utils, onSwipe };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('SwipeStack', () => {
|
|
45
|
+
it('renders the topmost card', () => {
|
|
46
|
+
const { getByText, queryByText } = renderStack();
|
|
47
|
+
expect(getByText('Card A')).toBeDefined();
|
|
48
|
+
expect(queryByText('Card C')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('labels the swipeable region', () => {
|
|
52
|
+
const { getByRole } = renderStack();
|
|
53
|
+
expect(getByRole('region', { name: "Today's agenda" })).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('only renders a fallback button for configured directions', () => {
|
|
57
|
+
const { getByRole, queryByRole } = renderStack();
|
|
58
|
+
expect(getByRole('button', { name: 'Done' })).toBeDefined();
|
|
59
|
+
expect(getByRole('button', { name: 'Defer' })).toBeDefined();
|
|
60
|
+
expect(queryByRole('button', { name: 'Skip' })).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('calls onSwipe with the direction and cardId when a drag crosses threshold', () => {
|
|
64
|
+
const { getByText, onSwipe } = renderStack();
|
|
65
|
+
const card = getByText('Card A');
|
|
66
|
+
fireEvent.pointerDown(card, pointer(0, 0));
|
|
67
|
+
fireEvent.pointerMove(card, pointer(0, -200));
|
|
68
|
+
fireEvent.pointerUp(card, pointer(0, -200));
|
|
69
|
+
expect(onSwipe).toHaveBeenCalledWith('up', 'a');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not call onSwipe when dragging toward a direction that is not configured', () => {
|
|
73
|
+
const { getByText, onSwipe } = renderStack();
|
|
74
|
+
const card = getByText('Card A');
|
|
75
|
+
fireEvent.pointerDown(card, pointer(0, 0));
|
|
76
|
+
fireEvent.pointerMove(card, pointer(200, 0));
|
|
77
|
+
fireEvent.pointerUp(card, pointer(200, 0));
|
|
78
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('calls onSwipe when a fallback button is clicked, without any drag', () => {
|
|
82
|
+
const { getByRole, onSwipe } = renderStack();
|
|
83
|
+
fireEvent.click(getByRole('button', { name: 'Defer' }));
|
|
84
|
+
expect(onSwipe).toHaveBeenCalledWith('down', 'a');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('advances to the next card only after the exit animation completes', () => {
|
|
88
|
+
vi.useFakeTimers();
|
|
89
|
+
const { getByRole, getByText, queryByText } = renderStack();
|
|
90
|
+
expect(getByText('Card A')).toBeDefined();
|
|
91
|
+
|
|
92
|
+
fireEvent.click(getByRole('button', { name: 'Done' }));
|
|
93
|
+
// Still mid-animation — the committed card stays put (so the fling-out
|
|
94
|
+
// it already started doesn't get cut off by a remount) and is still the
|
|
95
|
+
// one the fallback buttons act on; Card B is already visible as the
|
|
96
|
+
// peek behind it, but only decoratively (aria-hidden, not interactive).
|
|
97
|
+
expect(getByText('Card A')).toBeDefined();
|
|
98
|
+
const peekedB = getByText('Card B');
|
|
99
|
+
expect(peekedB.closest('[aria-hidden="true"]')).not.toBeNull();
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
vi.advanceTimersByTime(SWIPE_STACK_TRANSITION_MS);
|
|
103
|
+
});
|
|
104
|
+
expect(queryByText('Card A')).toBeNull();
|
|
105
|
+
expect(getByText('Card B').closest('[aria-hidden="true"]')).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('never shows a dismissed card again, even if the caller still passes it as a child', () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
const onSwipe = vi.fn();
|
|
111
|
+
const directions = { up: { label: 'Done', icon: 'check' as const } };
|
|
112
|
+
const { getByRole, getByText, queryByText, rerender } = render(
|
|
113
|
+
<SwipeStack aria-label="Today's agenda" directions={directions} onSwipe={onSwipe}>
|
|
114
|
+
<SwipeStackCard cardId="a">Card A</SwipeStackCard>
|
|
115
|
+
<SwipeStackCard cardId="b">Card B</SwipeStackCard>
|
|
116
|
+
</SwipeStack>,
|
|
117
|
+
);
|
|
118
|
+
fireEvent.click(getByRole('button', { name: 'Done' }));
|
|
119
|
+
act(() => {
|
|
120
|
+
vi.advanceTimersByTime(SWIPE_STACK_TRANSITION_MS);
|
|
121
|
+
});
|
|
122
|
+
expect(getByText('Card B')).toBeDefined();
|
|
123
|
+
|
|
124
|
+
// Re-render with the exact same children — a caller slow to remove the
|
|
125
|
+
// dismissed card from its own data. SwipeStack must not show it again.
|
|
126
|
+
rerender(
|
|
127
|
+
<SwipeStack aria-label="Today's agenda" directions={directions} onSwipe={onSwipe}>
|
|
128
|
+
<SwipeStackCard cardId="a">Card A</SwipeStackCard>
|
|
129
|
+
<SwipeStackCard cardId="b">Card B</SwipeStackCard>
|
|
130
|
+
</SwipeStack>,
|
|
131
|
+
);
|
|
132
|
+
expect(queryByText('Card A')).toBeNull();
|
|
133
|
+
expect(getByText('Card B')).toBeDefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('SwipeStack dev-mode child-type guard', () => {
|
|
138
|
+
function renderWithStrayChild() {
|
|
139
|
+
return render(
|
|
140
|
+
<SwipeStack aria-label="Today's agenda" directions={{}} onSwipe={() => {}}>
|
|
141
|
+
<SwipeStackCard cardId="a">Card A</SwipeStackCard>
|
|
142
|
+
{/* stray non-SwipeStackCard child */}
|
|
143
|
+
<div>oops</div>
|
|
144
|
+
</SwipeStack>,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('warns via console.error in development, mentioning the offending child', () => {
|
|
149
|
+
const originalEnv = process.env.NODE_ENV;
|
|
150
|
+
vi.stubEnv('NODE_ENV', 'development');
|
|
151
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
152
|
+
|
|
153
|
+
expect(() => renderWithStrayChild()).not.toThrow();
|
|
154
|
+
|
|
155
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('<div>'));
|
|
156
|
+
vi.stubEnv('NODE_ENV', originalEnv ?? 'test');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not call console.error in production', () => {
|
|
160
|
+
const originalEnv = process.env.NODE_ENV;
|
|
161
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
162
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
163
|
+
|
|
164
|
+
expect(() => renderWithStrayChild()).not.toThrow();
|
|
165
|
+
|
|
166
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
167
|
+
vi.stubEnv('NODE_ENV', originalEnv ?? 'test');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
top: 0;
|
|
4
4
|
/* Sits above sidebar rail (z-index ~10) but below dialogs/overlays (z-index 100) */
|
|
5
5
|
z-index: 50;
|
|
6
|
+
/* WebKit momentum-scroll re-tiling quirk: a position: sticky element with
|
|
7
|
+
no compositing layer of its own can go stale/blank for a stretch of
|
|
8
|
+
frames during a fast scroll on iOS Safari instead of repainting at its
|
|
9
|
+
stuck position. translateZ(0) promotes it to its own layer, rasterized
|
|
10
|
+
independently of the scroller's tile grid. See
|
|
11
|
+
docs/architecture-rules.md — found and fixed across three sticky
|
|
12
|
+
elements in sovereign-tasks, applied here proactively since this
|
|
13
|
+
component is exactly the same pattern (sticky, inside scrolling
|
|
14
|
+
content). */
|
|
15
|
+
transform: translateZ(0);
|
|
6
16
|
display: flex;
|
|
7
17
|
align-items: center;
|
|
8
18
|
justify-content: center;
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
.head .row {
|
|
23
|
-
border-bottom:
|
|
23
|
+
border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
.body .row {
|
|
27
|
-
border-bottom:
|
|
27
|
+
border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
.body .row:last-child {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
.tablist {
|
|
2
2
|
display: flex;
|
|
3
|
-
border-bottom:
|
|
3
|
+
border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
4
4
|
/* Horizontal scroll on mobile — scrollbar hidden for clean appearance */
|
|
5
5
|
overflow-x: auto;
|
|
6
6
|
scrollbar-width: none;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
padding: 5px var(--sv-space-2);
|
|
16
16
|
color: var(--sv-color-text-primary);
|
|
17
17
|
background: var(--sv-color-surface);
|
|
18
|
-
border:
|
|
18
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
|
|
19
19
|
border-radius: var(--sv-radius-md);
|
|
20
20
|
transition:
|
|
21
21
|
border-color 0.15s ease,
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
padding-inline: var(--sv-space-2) 2px;
|
|
51
51
|
color: var(--sv-color-text-primary);
|
|
52
52
|
background: var(--sv-color-surface-sunken);
|
|
53
|
-
border:
|
|
53
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
54
54
|
border-radius: var(--sv-radius-full);
|
|
55
55
|
font-family: var(--sv-font-family);
|
|
56
56
|
font-size: var(--sv-font-size-xs);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
line-height: 1.5;
|
|
8
8
|
color: var(--sv-color-text-primary);
|
|
9
9
|
background-color: var(--sv-color-surface);
|
|
10
|
-
border:
|
|
10
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
|
|
11
11
|
border-radius: var(--sv-radius-md);
|
|
12
12
|
resize: vertical;
|
|
13
13
|
transition:
|
|
@@ -37,3 +37,11 @@
|
|
|
37
37
|
cursor: not-allowed;
|
|
38
38
|
resize: none;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/* Height is driven by the `autoGrow` layout effect (Textarea.tsx), not the
|
|
42
|
+
user — resize: none avoids a drag handle that would fight it; overflow
|
|
43
|
+
hidden avoids a scrollbar flashing on between the old and new height. */
|
|
44
|
+
.textareaAutoGrow {
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
resize: none;
|
|
47
|
+
}
|
|
@@ -1,15 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
1
4
|
import type { TextareaHTMLAttributes } from 'react';
|
|
2
5
|
import styles from './Textarea.module.css';
|
|
3
6
|
|
|
4
|
-
export
|
|
7
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
8
|
+
/** Grows the field's own height to fit its content as it's typed, instead
|
|
9
|
+
* of scrolling internally or leaving the user to drag-resize — e.g. an
|
|
10
|
+
* editable title that should read as a heading, not a fixed-size form
|
|
11
|
+
* field. Disables manual resize (`resize: none`) since a height driven by
|
|
12
|
+
* content and a user-draggable height would fight each other. `rows` is
|
|
13
|
+
* still honoured as the starting/minimum height. */
|
|
14
|
+
autoGrow?: boolean;
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
18
|
* Textarea — the primitive multi-line text field. Presentational and
|
|
8
|
-
* RSC-safe
|
|
19
|
+
* RSC-safe unless `autoGrow` is used (client-only, needs a layout
|
|
20
|
+
* measurement). Forwards all native textarea props to the underlying
|
|
9
21
|
* `<textarea>`. Styling references `--sv-*` tokens via CSS Modules; there
|
|
10
22
|
* are no hardcoded values.
|
|
11
23
|
*/
|
|
12
|
-
export function Textarea({ className, rows = 4, ...rest }: TextareaProps) {
|
|
13
|
-
const
|
|
14
|
-
|
|
24
|
+
export function Textarea({ className, rows = 4, autoGrow = false, ...rest }: TextareaProps) {
|
|
25
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
const el = ref.current;
|
|
29
|
+
if (!autoGrow || !el) return;
|
|
30
|
+
el.style.height = 'auto';
|
|
31
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
32
|
+
}, [autoGrow, rest.value, rest.defaultValue]);
|
|
33
|
+
|
|
34
|
+
const classes = [styles.textarea, autoGrow ? styles.textareaAutoGrow : '', className]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(' ');
|
|
37
|
+
return <textarea ref={ref} rows={rows} className={classes} {...rest} />;
|
|
15
38
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
4
|
import { Textarea } from '../Textarea';
|
|
5
5
|
|
|
@@ -26,4 +26,26 @@ describe('Textarea', () => {
|
|
|
26
26
|
render(<Textarea aria-label="field" disabled />);
|
|
27
27
|
expect((screen.getByLabelText('field') as HTMLTextAreaElement).disabled).toBe(true);
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
it('does not apply the auto-grow class by default', () => {
|
|
31
|
+
render(<Textarea aria-label="field" />);
|
|
32
|
+
expect(screen.getByLabelText('field').className).not.toMatch(/textareaAutoGrow/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies the auto-grow class and sizes height to scrollHeight when autoGrow is set', () => {
|
|
36
|
+
// jsdom has no real layout engine — scrollHeight is always 0, so it must
|
|
37
|
+
// be stubbed on the prototype before mount (same pattern as
|
|
38
|
+
// MessageScroller's own tests) for useLayoutEffect's synchronous
|
|
39
|
+
// on-mount measurement to read anything meaningful. This only proves the
|
|
40
|
+
// effect reads scrollHeight and writes it back as `style.height`, not
|
|
41
|
+
// that real pixel math is correct.
|
|
42
|
+
const scrollHeightSpy = vi
|
|
43
|
+
.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get')
|
|
44
|
+
.mockReturnValue(88);
|
|
45
|
+
render(<Textarea aria-label="field" autoGrow defaultValue="hello" />);
|
|
46
|
+
const el = screen.getByLabelText('field') as HTMLTextAreaElement;
|
|
47
|
+
expect(el.className).toMatch(/textareaAutoGrow/);
|
|
48
|
+
expect(el.style.height).toBe('88px');
|
|
49
|
+
scrollHeightSpy.mockRestore();
|
|
50
|
+
});
|
|
29
51
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.shell {
|
|
2
|
+
display: flex;
|
|
3
|
+
height: 100%;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.sidebar {
|
|
8
|
+
flex-shrink: 0;
|
|
9
|
+
overflow-y: auto;
|
|
10
|
+
background: var(--sv-color-surface-raised);
|
|
11
|
+
border-right: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* `display: none`, not `width: 0` — a zero-width flex item still paints its
|
|
16
|
+
* own `border-right` as a 1px hairline down the edge of the viewport, which
|
|
17
|
+
* reads as a stray line rather than a hidden column.
|
|
18
|
+
*/
|
|
19
|
+
.sidebarHidden {
|
|
20
|
+
display: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.main {
|
|
24
|
+
flex: 1;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
background: var(--sv-color-surface);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.detail {
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
overflow-y: auto;
|
|
33
|
+
background: var(--sv-color-surface-raised);
|
|
34
|
+
border-left: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
35
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { ThreeColumnLayout } from './ThreeColumnLayout';
|
|
4
|
+
|
|
5
|
+
function Block({
|
|
6
|
+
title,
|
|
7
|
+
children,
|
|
8
|
+
padded = true,
|
|
9
|
+
}: {
|
|
10
|
+
title: string;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
padded?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
style={{
|
|
17
|
+
height: '100%',
|
|
18
|
+
padding: padded ? 'var(--sv-space-4)' : 0,
|
|
19
|
+
fontFamily: 'var(--sv-font-family)',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<h3
|
|
23
|
+
style={{
|
|
24
|
+
margin: '0 0 var(--sv-space-2)',
|
|
25
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
26
|
+
color: 'var(--sv-color-text-primary)',
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
{title}
|
|
30
|
+
</h3>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Components/ThreeColumnLayout',
|
|
38
|
+
component: ThreeColumnLayout,
|
|
39
|
+
parameters: {
|
|
40
|
+
layout: 'fullscreen',
|
|
41
|
+
docs: {
|
|
42
|
+
description: {
|
|
43
|
+
component:
|
|
44
|
+
'Sidebar + main + optional detail column, for the common "list app" shape. Purely positional: pass 2 children for sidebar+main, or 3 for sidebar+main+detail. The layout has no awareness of what is inside each slot and no responsive behavior of its own.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
args: {
|
|
49
|
+
children: [<Block key="sidebar" title="Sidebar" />, <Block key="main" title="Main" />],
|
|
50
|
+
},
|
|
51
|
+
} satisfies Meta<typeof ThreeColumnLayout>;
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
type Story = StoryObj<typeof meta>;
|
|
55
|
+
|
|
56
|
+
/** The canonical two-child usage — sidebar + main, no detail column. */
|
|
57
|
+
export const Default: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div style={{ height: '480px' }}>
|
|
60
|
+
<ThreeColumnLayout>
|
|
61
|
+
<Block title="Sidebar">
|
|
62
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
|
|
63
|
+
<li>Groceries</li>
|
|
64
|
+
<li>Work</li>
|
|
65
|
+
<li>Personal</li>
|
|
66
|
+
</ul>
|
|
67
|
+
</Block>
|
|
68
|
+
<Block title="Main">No item selected — main fills the full remaining width.</Block>
|
|
69
|
+
</ThreeColumnLayout>
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const ThreeColumns: Story = {
|
|
75
|
+
render: () => (
|
|
76
|
+
<div style={{ height: '480px' }}>
|
|
77
|
+
<ThreeColumnLayout>
|
|
78
|
+
<Block title="Sidebar">
|
|
79
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
|
|
80
|
+
<li>Groceries</li>
|
|
81
|
+
<li>Work</li>
|
|
82
|
+
<li>Personal</li>
|
|
83
|
+
</ul>
|
|
84
|
+
</Block>
|
|
85
|
+
<Block title="Main">
|
|
86
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--sv-space-4)' }}>
|
|
87
|
+
<li>Buy milk</li>
|
|
88
|
+
<li>Buy eggs</li>
|
|
89
|
+
</ul>
|
|
90
|
+
</Block>
|
|
91
|
+
<Block title="Detail">Editing "Buy milk" — notes, due date, etc.</Block>
|
|
92
|
+
</ThreeColumnLayout>
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const CustomWidths: Story = {
|
|
98
|
+
render: () => (
|
|
99
|
+
<div style={{ height: '480px' }}>
|
|
100
|
+
<ThreeColumnLayout sidebarWidth={200} detailWidth={440}>
|
|
101
|
+
<Block title="Sidebar (200px)">Narrower nav column.</Block>
|
|
102
|
+
<Block title="Main">Fills whatever space is left.</Block>
|
|
103
|
+
<Block title="Detail (440px)">Wider detail column for a richer editor.</Block>
|
|
104
|
+
</ThreeColumnLayout>
|
|
105
|
+
</div>
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Demonstrates the intended usage: the detail column appears only once
|
|
110
|
+
* something is selected, by conditionally including the third child. */
|
|
111
|
+
export const ConditionalDetail: Story = {
|
|
112
|
+
render: function Render() {
|
|
113
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
114
|
+
const items = ['Buy milk', 'Buy eggs', 'Call plumber'];
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div style={{ height: '480px' }}>
|
|
118
|
+
<ThreeColumnLayout>
|
|
119
|
+
<Block title="Sidebar">Lists</Block>
|
|
120
|
+
<Block title="Main" padded={false}>
|
|
121
|
+
<div style={{ padding: 'var(--sv-space-4)' }}>
|
|
122
|
+
{items.map((item) => (
|
|
123
|
+
<button
|
|
124
|
+
key={item}
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => setSelectedId(item)}
|
|
127
|
+
style={{
|
|
128
|
+
display: 'block',
|
|
129
|
+
width: '100%',
|
|
130
|
+
textAlign: 'left',
|
|
131
|
+
padding: 'var(--sv-space-2)',
|
|
132
|
+
marginBottom: 'var(--sv-space-1)',
|
|
133
|
+
background:
|
|
134
|
+
selectedId === item ? 'var(--sv-color-accent-subtle)' : 'transparent',
|
|
135
|
+
border: 'none',
|
|
136
|
+
borderRadius: 'var(--sv-radius-sm)',
|
|
137
|
+
cursor: 'pointer',
|
|
138
|
+
font: 'inherit',
|
|
139
|
+
color: 'var(--sv-color-text-primary)',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{item}
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</Block>
|
|
147
|
+
{selectedId && <Block title="Detail">Editing "{selectedId}"</Block>}
|
|
148
|
+
</ThreeColumnLayout>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Children } from 'react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import styles from './ThreeColumnLayout.module.css';
|
|
6
|
+
|
|
7
|
+
export interface ThreeColumnLayoutProps {
|
|
8
|
+
/**
|
|
9
|
+
* Exactly 2 (sidebar, main) or 3 (sidebar, main, detail) elements. Omit
|
|
10
|
+
* the third child entirely to render two columns — e.g.
|
|
11
|
+
* `{selected && <Detail />}` — React.Children.toArray drops falsy
|
|
12
|
+
* children, so no separate `detail` prop is needed to make that
|
|
13
|
+
* conditional.
|
|
14
|
+
*/
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
/** px. Fixed width of the first (leftmost) column. Default 280. */
|
|
17
|
+
sidebarWidth?: number;
|
|
18
|
+
/** px. Fixed width of the third column, when present. Default 360. */
|
|
19
|
+
detailWidth?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Hides the sidebar column entirely — no width, no border, no layout box
|
|
22
|
+
* — while keeping it mounted and every sibling in its own stable position.
|
|
23
|
+
*
|
|
24
|
+
* Prefer this over conditionally omitting the sidebar child, or swapping
|
|
25
|
+
* to a different wrapper when collapsed. Omitting it shifts `main` into
|
|
26
|
+
* the sidebar slot, and swapping the surrounding element type unmounts
|
|
27
|
+
* `main`'s entire subtree — silently discarding its React state (a live
|
|
28
|
+
* stream, unsent composer text, an in-memory-only conversation) on every
|
|
29
|
+
* collapse toggle. Found live in Warden, whose chat column lost all of
|
|
30
|
+
* that each time the sidebar was shown or hidden.
|
|
31
|
+
*/
|
|
32
|
+
sidebarHidden?: boolean;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ThreeColumnLayout — sidebar + main + optional detail column, for the
|
|
38
|
+
* common "list app" shape (a list sidebar, a primary content list, and a
|
|
39
|
+
* detail pane that only takes up space once something is selected).
|
|
40
|
+
*
|
|
41
|
+
* Purely positional and structural — it has no awareness of what's inside
|
|
42
|
+
* each slot (no injected landmarks/labels; those belong to the children
|
|
43
|
+
* themselves) and no responsive behavior of its own. Collapsing further at
|
|
44
|
+
* narrow desktop/tablet widths (e.g. swapping the detail column for an
|
|
45
|
+
* overlay) is the consuming plugin's decision; below the mobile breakpoint,
|
|
46
|
+
* compose with ResponsiveSurface to fork to a different tree entirely.
|
|
47
|
+
*/
|
|
48
|
+
export function ThreeColumnLayout({
|
|
49
|
+
children,
|
|
50
|
+
sidebarWidth = 280,
|
|
51
|
+
detailWidth = 360,
|
|
52
|
+
sidebarHidden = false,
|
|
53
|
+
className,
|
|
54
|
+
}: ThreeColumnLayoutProps) {
|
|
55
|
+
const items = Children.toArray(children);
|
|
56
|
+
|
|
57
|
+
if (process.env.NODE_ENV !== 'production' && (items.length < 2 || items.length > 3)) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`[ThreeColumnLayout] Expects 2 or 3 children (sidebar, main, optional detail); received ${items.length}.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [sidebar, main, detail] = items;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={[styles.shell, className].filter(Boolean).join(' ')}>
|
|
67
|
+
{/* `hidden` (not just the class) so the column leaves the
|
|
68
|
+
accessibility tree and the tab order too, rather than staying
|
|
69
|
+
reachable by keyboard while invisible. */}
|
|
70
|
+
<div
|
|
71
|
+
className={[styles.sidebar, sidebarHidden && styles.sidebarHidden]
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(' ')}
|
|
74
|
+
style={sidebarHidden ? undefined : { width: sidebarWidth }}
|
|
75
|
+
hidden={sidebarHidden}
|
|
76
|
+
>
|
|
77
|
+
{sidebar}
|
|
78
|
+
</div>
|
|
79
|
+
<div className={styles.main}>{main}</div>
|
|
80
|
+
{detail && (
|
|
81
|
+
<div className={styles.detail} style={{ width: detailWidth }}>
|
|
82
|
+
{detail}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { ThreeColumnLayout } from '../ThreeColumnLayout';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('ThreeColumnLayout', () => {
|
|
9
|
+
it('renders two columns and no detail column when only sidebar + main are given', () => {
|
|
10
|
+
render(
|
|
11
|
+
<ThreeColumnLayout>
|
|
12
|
+
<nav>Sidebar</nav>
|
|
13
|
+
<main>Main</main>
|
|
14
|
+
</ThreeColumnLayout>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByText('Sidebar')).toBeDefined();
|
|
18
|
+
expect(screen.getByText('Main')).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders the detail column when a third child is given', () => {
|
|
22
|
+
render(
|
|
23
|
+
<ThreeColumnLayout>
|
|
24
|
+
<nav>Sidebar</nav>
|
|
25
|
+
<main>Main</main>
|
|
26
|
+
<aside>Detail</aside>
|
|
27
|
+
</ThreeColumnLayout>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText('Detail')).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('omits the detail column when the third child is conditionally falsy', () => {
|
|
34
|
+
const selected = false;
|
|
35
|
+
render(
|
|
36
|
+
<ThreeColumnLayout>
|
|
37
|
+
<nav>Sidebar</nav>
|
|
38
|
+
<main>Main</main>
|
|
39
|
+
{selected && <aside>Detail</aside>}
|
|
40
|
+
</ThreeColumnLayout>,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(screen.queryByText('Detail')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('applies sidebarWidth and detailWidth as inline widths', () => {
|
|
47
|
+
render(
|
|
48
|
+
<ThreeColumnLayout sidebarWidth={240} detailWidth={320}>
|
|
49
|
+
<nav>Sidebar</nav>
|
|
50
|
+
<main>Main</main>
|
|
51
|
+
<aside>Detail</aside>
|
|
52
|
+
</ThreeColumnLayout>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText('Sidebar').parentElement?.style.width).toBe('240px');
|
|
56
|
+
expect(screen.getByText('Detail').parentElement?.style.width).toBe('320px');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('warns in development when given fewer than 2 or more than 3 children', () => {
|
|
60
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
61
|
+
|
|
62
|
+
render(<ThreeColumnLayout>{[<main key="only">Only</main>]}</ThreeColumnLayout>);
|
|
63
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('received 1'));
|
|
64
|
+
|
|
65
|
+
warn.mockClear();
|
|
66
|
+
render(
|
|
67
|
+
<ThreeColumnLayout>
|
|
68
|
+
<nav>A</nav>
|
|
69
|
+
<main>B</main>
|
|
70
|
+
<aside>C</aside>
|
|
71
|
+
<aside>D</aside>
|
|
72
|
+
</ThreeColumnLayout>,
|
|
73
|
+
);
|
|
74
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('received 4'));
|
|
75
|
+
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
});
|