@sovereignfs/ui 0.41.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './Table';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Table',
|
|
6
|
+
component: Table,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'padded',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Thin, styled wrappers around the native table elements. Not a data grid — no sort/filter/virtualization. Horizontal scroll (masked-edge fade, hidden scrollbar) at any viewport size.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: { children: null },
|
|
17
|
+
} satisfies Meta<typeof Table>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const ROWS = [
|
|
25
|
+
{ name: 'sovereign-tasks', status: 'Active', version: '1.4.2' },
|
|
26
|
+
{ name: 'sovereign-ledger', status: 'Active', version: '2.0.0' },
|
|
27
|
+
{ name: 'sovereign-shopper', status: 'Disabled', version: '0.9.1' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<Table>
|
|
33
|
+
<TableHead>
|
|
34
|
+
<TableRow>
|
|
35
|
+
<TableHeaderCell>Plugin</TableHeaderCell>
|
|
36
|
+
<TableHeaderCell>Status</TableHeaderCell>
|
|
37
|
+
<TableHeaderCell>Version</TableHeaderCell>
|
|
38
|
+
</TableRow>
|
|
39
|
+
</TableHead>
|
|
40
|
+
<TableBody>
|
|
41
|
+
{ROWS.map((row) => (
|
|
42
|
+
<TableRow key={row.name}>
|
|
43
|
+
<TableCell>{row.name}</TableCell>
|
|
44
|
+
<TableCell>{row.status}</TableCell>
|
|
45
|
+
<TableCell>{row.version}</TableCell>
|
|
46
|
+
</TableRow>
|
|
47
|
+
))}
|
|
48
|
+
</TableBody>
|
|
49
|
+
</Table>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithoutHeader: Story = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<Table>
|
|
56
|
+
<TableBody>
|
|
57
|
+
{ROWS.map((row) => (
|
|
58
|
+
<TableRow key={row.name}>
|
|
59
|
+
<TableCell>{row.name}</TableCell>
|
|
60
|
+
<TableCell>{row.status}</TableCell>
|
|
61
|
+
</TableRow>
|
|
62
|
+
))}
|
|
63
|
+
</TableBody>
|
|
64
|
+
</Table>
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const LongContent: Story = {
|
|
69
|
+
render: () => (
|
|
70
|
+
<Table>
|
|
71
|
+
<TableHead>
|
|
72
|
+
<TableRow>
|
|
73
|
+
<TableHeaderCell>Plugin</TableHeaderCell>
|
|
74
|
+
<TableHeaderCell>Description</TableHeaderCell>
|
|
75
|
+
<TableHeaderCell>Author</TableHeaderCell>
|
|
76
|
+
<TableHeaderCell>Repository</TableHeaderCell>
|
|
77
|
+
<TableHeaderCell>Status</TableHeaderCell>
|
|
78
|
+
<TableHeaderCell>Version</TableHeaderCell>
|
|
79
|
+
</TableRow>
|
|
80
|
+
</TableHead>
|
|
81
|
+
<TableBody>
|
|
82
|
+
{ROWS.map((row) => (
|
|
83
|
+
<TableRow key={row.name}>
|
|
84
|
+
<TableCell>{row.name}</TableCell>
|
|
85
|
+
<TableCell>A longer description column to force horizontal overflow</TableCell>
|
|
86
|
+
<TableCell>sovereign-community</TableCell>
|
|
87
|
+
<TableCell>github.com/sovereignfs/{row.name}</TableCell>
|
|
88
|
+
<TableCell>{row.status}</TableCell>
|
|
89
|
+
<TableCell>{row.version}</TableCell>
|
|
90
|
+
</TableRow>
|
|
91
|
+
))}
|
|
92
|
+
</TableBody>
|
|
93
|
+
</Table>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const NarrowViewport: Story = {
|
|
98
|
+
parameters: { viewport: { defaultViewport: 'mobile1' } },
|
|
99
|
+
render: () => (
|
|
100
|
+
<Table>
|
|
101
|
+
<TableHead>
|
|
102
|
+
<TableRow>
|
|
103
|
+
<TableHeaderCell>Plugin</TableHeaderCell>
|
|
104
|
+
<TableHeaderCell>Description</TableHeaderCell>
|
|
105
|
+
<TableHeaderCell>Author</TableHeaderCell>
|
|
106
|
+
<TableHeaderCell>Repository</TableHeaderCell>
|
|
107
|
+
<TableHeaderCell>Status</TableHeaderCell>
|
|
108
|
+
<TableHeaderCell>Version</TableHeaderCell>
|
|
109
|
+
</TableRow>
|
|
110
|
+
</TableHead>
|
|
111
|
+
<TableBody>
|
|
112
|
+
{ROWS.map((row) => (
|
|
113
|
+
<TableRow key={row.name}>
|
|
114
|
+
<TableCell>{row.name}</TableCell>
|
|
115
|
+
<TableCell>A longer description column to force horizontal overflow</TableCell>
|
|
116
|
+
<TableCell>sovereign-community</TableCell>
|
|
117
|
+
<TableCell>github.com/sovereignfs/{row.name}</TableCell>
|
|
118
|
+
<TableCell>{row.status}</TableCell>
|
|
119
|
+
<TableCell>{row.version}</TableCell>
|
|
120
|
+
</TableRow>
|
|
121
|
+
))}
|
|
122
|
+
</TableBody>
|
|
123
|
+
</Table>
|
|
124
|
+
),
|
|
125
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
|
|
2
|
+
import styles from './Table.module.css';
|
|
3
|
+
|
|
4
|
+
export interface TableProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Table — thin, styled wrappers around the native table elements.
|
|
11
|
+
*
|
|
12
|
+
* Not a data grid: no sort, filter, or virtualization. `Table` wraps its
|
|
13
|
+
* content in a horizontally-scrolling container (masked-edge fade, hidden
|
|
14
|
+
* scrollbar — same technique as `NavTabs`) so a table wider than its
|
|
15
|
+
* container scrolls instead of overflowing the page, at any viewport size.
|
|
16
|
+
*/
|
|
17
|
+
export function Table({ children, className }: TableProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={styles.scrollContainer}>
|
|
20
|
+
<table className={[styles.table, className].filter(Boolean).join(' ')}>{children}</table>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TableHead({ children }: { children: ReactNode }) {
|
|
26
|
+
return <thead className={styles.head}>{children}</thead>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TableBody({ children }: { children: ReactNode }) {
|
|
30
|
+
return <tbody className={styles.body}>{children}</tbody>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TableRow({ children }: { children: ReactNode }) {
|
|
34
|
+
return <tr className={styles.row}>{children}</tr>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function TableHeaderCell({ children, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
38
|
+
return (
|
|
39
|
+
<th className={styles.th} {...rest}>
|
|
40
|
+
{children}
|
|
41
|
+
</th>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function TableCell({ children, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
46
|
+
return (
|
|
47
|
+
<td className={styles.td} {...rest}>
|
|
48
|
+
{children}
|
|
49
|
+
</td>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../Table';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
function Demo() {
|
|
9
|
+
return (
|
|
10
|
+
<Table>
|
|
11
|
+
<TableHead>
|
|
12
|
+
<TableRow>
|
|
13
|
+
<TableHeaderCell>Name</TableHeaderCell>
|
|
14
|
+
<TableHeaderCell>Status</TableHeaderCell>
|
|
15
|
+
</TableRow>
|
|
16
|
+
</TableHead>
|
|
17
|
+
<TableBody>
|
|
18
|
+
<TableRow>
|
|
19
|
+
<TableCell>Alpha</TableCell>
|
|
20
|
+
<TableCell>Active</TableCell>
|
|
21
|
+
</TableRow>
|
|
22
|
+
<TableRow>
|
|
23
|
+
<TableCell>Beta</TableCell>
|
|
24
|
+
<TableCell>Disabled</TableCell>
|
|
25
|
+
</TableRow>
|
|
26
|
+
</TableBody>
|
|
27
|
+
</Table>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('Table', () => {
|
|
32
|
+
it('renders a real table element', () => {
|
|
33
|
+
render(<Demo />);
|
|
34
|
+
expect(screen.getByRole('table')).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders header cells with columnheader role', () => {
|
|
38
|
+
render(<Demo />);
|
|
39
|
+
expect(screen.getAllByRole('columnheader').map((el) => el.textContent)).toEqual([
|
|
40
|
+
'Name',
|
|
41
|
+
'Status',
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders the correct number of body rows and cells', () => {
|
|
46
|
+
render(<Demo />);
|
|
47
|
+
expect(screen.getAllByRole('row').length).toBe(3); // 1 header + 2 body
|
|
48
|
+
expect(screen.getAllByRole('cell').length).toBe(4);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
.tab {
|
|
14
14
|
flex-shrink: 0;
|
|
15
|
-
|
|
15
|
+
/* 36px rendered height, matching this scale's other 36px controls (Input,
|
|
16
|
+
Select). Vertical padding hardcoded to hit the exact height, same
|
|
17
|
+
reasoning as Button — accounting for the 2px border-bottom below,
|
|
18
|
+
which (content-box) adds to the rendered height on top of font-size +
|
|
19
|
+
padding. */
|
|
20
|
+
padding: 10px var(--sv-space-4);
|
|
16
21
|
border: none;
|
|
17
22
|
border-bottom: 2px solid transparent;
|
|
18
23
|
/* Negative margin pulls the 2px border flush with the tablist border-bottom */
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
.track {
|
|
2
|
-
/* 38×22px pill — spec dimensions
|
|
2
|
+
/* 38×22px pill — spec dimensions. Sizing audit: despite the component
|
|
3
|
+
name, this renders as a binary pill switch (role="switch"), not a
|
|
4
|
+
pressable toggle button — reviewed against this scale's other compact
|
|
5
|
+
controls with that in mind. Kept dimensions unchanged, generously
|
|
6
|
+
sized for touch-friendliness. (The name vs. actual behavior mismatch
|
|
7
|
+
is a separate, pre-existing naming note, not addressed here.) */
|
|
3
8
|
display: inline-flex;
|
|
4
9
|
align-items: center;
|
|
5
10
|
width: 38px;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.h1 {
|
|
2
|
+
margin: 0;
|
|
3
|
+
font-size: var(--sv-font-size-2xl);
|
|
4
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
5
|
+
color: var(--sv-color-text-primary);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.h2 {
|
|
9
|
+
margin: 0;
|
|
10
|
+
font-size: var(--sv-font-size-xl);
|
|
11
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
12
|
+
color: var(--sv-color-text-primary);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.h3 {
|
|
16
|
+
margin: 0;
|
|
17
|
+
font-size: var(--sv-font-size-lg);
|
|
18
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
19
|
+
color: var(--sv-color-text-primary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.h4 {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-size: var(--sv-font-size-md);
|
|
25
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
26
|
+
color: var(--sv-color-text-primary);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
font-size: var(--sv-font-size-sm);
|
|
32
|
+
color: var(--sv-color-text-primary);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.caption {
|
|
36
|
+
margin: 0;
|
|
37
|
+
font-size: var(--sv-font-size-caption);
|
|
38
|
+
color: var(--sv-color-text-muted);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.label {
|
|
42
|
+
font-size: var(--sv-font-size-label);
|
|
43
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
44
|
+
color: var(--sv-color-text-muted);
|
|
45
|
+
text-transform: uppercase;
|
|
46
|
+
letter-spacing: 0.04em;
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Typography } from './Typography';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/Typography',
|
|
6
|
+
component: Typography,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: {
|
|
9
|
+
variant: 'body',
|
|
10
|
+
children: 'The quick brown fox',
|
|
11
|
+
},
|
|
12
|
+
} satisfies Meta<typeof Typography>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const H1: Story = {
|
|
20
|
+
args: { variant: 'h1' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const H2: Story = {
|
|
24
|
+
args: { variant: 'h2' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const H3: Story = {
|
|
28
|
+
args: { variant: 'h3' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const H4: Story = {
|
|
32
|
+
args: { variant: 'h4' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Body: Story = {
|
|
36
|
+
args: { variant: 'body' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const Caption: Story = {
|
|
40
|
+
args: { variant: 'caption' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const LabelVariant: Story = {
|
|
44
|
+
name: 'Label',
|
|
45
|
+
args: { variant: 'label', children: 'Section label' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const AllVariants: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
51
|
+
<Typography variant="h1">Heading 1</Typography>
|
|
52
|
+
<Typography variant="h2">Heading 2</Typography>
|
|
53
|
+
<Typography variant="h3">Heading 3</Typography>
|
|
54
|
+
<Typography variant="h4">Heading 4</Typography>
|
|
55
|
+
<Typography variant="body">
|
|
56
|
+
Body copy — the default reading size for paragraphs and descriptions.
|
|
57
|
+
</Typography>
|
|
58
|
+
<Typography variant="caption">Caption — secondary, supporting copy.</Typography>
|
|
59
|
+
<Typography variant="label">Section label</Typography>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from 'react';
|
|
2
|
+
import styles from './Typography.module.css';
|
|
3
|
+
|
|
4
|
+
export type TypographyVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'label';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TAG: Record<TypographyVariant, ElementType> = {
|
|
7
|
+
h1: 'h1',
|
|
8
|
+
h2: 'h2',
|
|
9
|
+
h3: 'h3',
|
|
10
|
+
h4: 'h4',
|
|
11
|
+
body: 'p',
|
|
12
|
+
caption: 'p',
|
|
13
|
+
label: 'span',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface TypographyProps {
|
|
17
|
+
variant: TypographyVariant;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** Override the rendered tag while keeping the variant's visual style —
|
|
20
|
+
* e.g. `variant="h1" as="div"` to avoid a second `<h1>` on a page that
|
|
21
|
+
* already has one. */
|
|
22
|
+
as?: ElementType;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Typography — text bound to the design system's font-size/weight scale. */
|
|
27
|
+
export function Typography({ variant, children, as, className }: TypographyProps) {
|
|
28
|
+
const Tag = as ?? DEFAULT_TAG[variant];
|
|
29
|
+
return <Tag className={[styles[variant], className].filter(Boolean).join(' ')}>{children}</Tag>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Typography } from '../Typography';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Typography', () => {
|
|
9
|
+
it('renders the default tag for each variant', () => {
|
|
10
|
+
render(<Typography variant="h1">Title</Typography>);
|
|
11
|
+
expect(screen.getByText('Title').tagName).toBe('H1');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders body as a <p> by default', () => {
|
|
15
|
+
render(<Typography variant="body">Body copy</Typography>);
|
|
16
|
+
expect(screen.getByText('Body copy').tagName).toBe('P');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders label as a <span> by default', () => {
|
|
20
|
+
render(<Typography variant="label">Section</Typography>);
|
|
21
|
+
expect(screen.getByText('Section').tagName).toBe('SPAN');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('overrides the rendered tag via `as` while keeping the variant class', () => {
|
|
25
|
+
render(
|
|
26
|
+
<Typography variant="h1" as="div">
|
|
27
|
+
Title
|
|
28
|
+
</Typography>,
|
|
29
|
+
);
|
|
30
|
+
const el = screen.getByText('Title');
|
|
31
|
+
expect(el.tagName).toBe('DIV');
|
|
32
|
+
expect(el.className).toContain('h1');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useCarouselRouteSync } from '../useCarouselRouteSync';
|
|
5
|
+
|
|
6
|
+
const SLIDES = ['/a', '/b', '/c'];
|
|
7
|
+
function indexForPathname(pathname: string) {
|
|
8
|
+
const i = SLIDES.indexOf(pathname);
|
|
9
|
+
return i === -1 ? 0 : i;
|
|
10
|
+
}
|
|
11
|
+
function pathForIndex(index: number) {
|
|
12
|
+
return SLIDES[index] ?? '/a';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Harness({
|
|
16
|
+
pathname,
|
|
17
|
+
onNavigate,
|
|
18
|
+
onResult,
|
|
19
|
+
}: {
|
|
20
|
+
pathname: string;
|
|
21
|
+
onNavigate: (path: string) => void;
|
|
22
|
+
onResult: (result: { activeIndex: number; onSettle: (i: number) => void }) => void;
|
|
23
|
+
}) {
|
|
24
|
+
const result = useCarouselRouteSync({ indexForPathname, pathForIndex, pathname, onNavigate });
|
|
25
|
+
onResult(result);
|
|
26
|
+
return <span data-testid="active">{result.activeIndex}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
describe('useCarouselRouteSync', () => {
|
|
32
|
+
it('initializes activeIndex from the current pathname', () => {
|
|
33
|
+
const onNavigate = vi.fn();
|
|
34
|
+
const { getByTestId } = render(
|
|
35
|
+
<Harness pathname="/b" onNavigate={onNavigate} onResult={() => {}} />,
|
|
36
|
+
);
|
|
37
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
38
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('an external pathname change (tapped Link, browser back/forward) updates activeIndex without calling onNavigate', () => {
|
|
42
|
+
const onNavigate = vi.fn();
|
|
43
|
+
const { getByTestId, rerender } = render(
|
|
44
|
+
<Harness pathname="/a" onNavigate={onNavigate} onResult={() => {}} />,
|
|
45
|
+
);
|
|
46
|
+
expect(getByTestId('active').textContent).toBe('0');
|
|
47
|
+
|
|
48
|
+
rerender(<Harness pathname="/c" onNavigate={onNavigate} onResult={() => {}} />);
|
|
49
|
+
expect(getByTestId('active').textContent).toBe('2');
|
|
50
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("onSettle calls onNavigate with pathForIndex's output and updates activeIndex synchronously", () => {
|
|
54
|
+
const onNavigate = vi.fn();
|
|
55
|
+
let latest: { activeIndex: number; onSettle: (i: number) => void } = {
|
|
56
|
+
activeIndex: -1,
|
|
57
|
+
onSettle: () => {},
|
|
58
|
+
};
|
|
59
|
+
const { getByTestId } = render(
|
|
60
|
+
<Harness
|
|
61
|
+
pathname="/a"
|
|
62
|
+
onNavigate={onNavigate}
|
|
63
|
+
onResult={(r) => {
|
|
64
|
+
latest = r;
|
|
65
|
+
}}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
act(() => void latest.onSettle(2));
|
|
70
|
+
|
|
71
|
+
expect(getByTestId('active').textContent).toBe('2');
|
|
72
|
+
expect(onNavigate).toHaveBeenCalledWith('/c');
|
|
73
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does NOT re-trigger the external-nav branch on the pathname-effect run that follows its own onSettle-driven navigation', () => {
|
|
77
|
+
// This is the isInternalNav dance: onSettle navigates via onNavigate; the
|
|
78
|
+
// caller's router then re-renders this hook with the new pathname — that
|
|
79
|
+
// pathname change must be recognized as "our own settle, already
|
|
80
|
+
// applied" and NOT reprocessed as an external nav. Both existing plugins'
|
|
81
|
+
// code comments describe a real double-flicker bug from getting this
|
|
82
|
+
// wrong, so this is the highest-value assertion in this file.
|
|
83
|
+
const onNavigate = vi.fn();
|
|
84
|
+
let latest: { activeIndex: number; onSettle: (i: number) => void } = {
|
|
85
|
+
activeIndex: -1,
|
|
86
|
+
onSettle: () => {},
|
|
87
|
+
};
|
|
88
|
+
const { getByTestId, rerender } = render(
|
|
89
|
+
<Harness
|
|
90
|
+
pathname="/a"
|
|
91
|
+
onNavigate={onNavigate}
|
|
92
|
+
onResult={(r) => {
|
|
93
|
+
latest = r;
|
|
94
|
+
}}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Settle on /b (index 1) — this is the carousel's own gesture-driven navigation.
|
|
99
|
+
act(() => void latest.onSettle(1));
|
|
100
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
101
|
+
expect(onNavigate).toHaveBeenCalledWith('/b');
|
|
102
|
+
|
|
103
|
+
// The caller's router "commits" the navigation by re-rendering with the
|
|
104
|
+
// new pathname (exactly what onNavigate's real router.replace would
|
|
105
|
+
// eventually cause). If this were misidentified as an external nav, the
|
|
106
|
+
// effect would recompute indexForPathname('/b') anyway — same value, so
|
|
107
|
+
// a bug here wouldn't show up as a *wrong* index, only as an extra,
|
|
108
|
+
// redundant setState (the actual symptom in the original bug was a
|
|
109
|
+
// visible re-snap/flicker, not a wrong end state) — so this test also
|
|
110
|
+
// asserts onNavigate is never called a second time as a side effect of
|
|
111
|
+
// that re-render, which would happen if the effect treated it as new
|
|
112
|
+
// settle-worthy input rather than a no-op resync.
|
|
113
|
+
rerender(<Harness pathname="/b" onNavigate={onNavigate} onResult={(r) => (latest = r)} />);
|
|
114
|
+
|
|
115
|
+
expect(getByTestId('active').textContent).toBe('1');
|
|
116
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('an unrecognized pathname falls back to index 0', () => {
|
|
120
|
+
const onNavigate = vi.fn();
|
|
121
|
+
const { getByTestId } = render(
|
|
122
|
+
<Harness pathname="/unknown" onNavigate={onNavigate} onResult={() => {}} />,
|
|
123
|
+
);
|
|
124
|
+
expect(getByTestId('active').textContent).toBe('0');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useIsOffline } from '../useIsOffline';
|
|
5
|
+
|
|
6
|
+
function Harness() {
|
|
7
|
+
const isOffline = useIsOffline();
|
|
8
|
+
return <span data-testid="out">{String(isOffline)}</span>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('useIsOffline', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
cleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reflects navigator.onLine after mount', () => {
|
|
21
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
22
|
+
const { getByTestId } = render(<Harness />);
|
|
23
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not report offline when navigator.onLine is true', () => {
|
|
27
|
+
const { getByTestId } = render(<Harness />);
|
|
28
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('flips on the offline event', () => {
|
|
32
|
+
const { getByTestId } = render(<Harness />);
|
|
33
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
34
|
+
act(() => window.dispatchEvent(new Event('offline')));
|
|
35
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('flips back on the online event', () => {
|
|
39
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
40
|
+
const { getByTestId } = render(<Harness />);
|
|
41
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
42
|
+
act(() => window.dispatchEvent(new Event('online')));
|
|
43
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('removes its listeners on unmount', () => {
|
|
47
|
+
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
|
48
|
+
const { unmount } = render(<Harness />);
|
|
49
|
+
unmount();
|
|
50
|
+
expect(removeSpy).toHaveBeenCalledWith('offline', expect.any(Function));
|
|
51
|
+
expect(removeSpy).toHaveBeenCalledWith('online', expect.any(Function));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { useOfflineTileState } from '../useOfflineTileState';
|
|
5
|
+
|
|
6
|
+
function Harness({
|
|
7
|
+
offline,
|
|
8
|
+
deviceOnlyAvailable,
|
|
9
|
+
}: {
|
|
10
|
+
offline: 'offline-first' | 'device-only' | undefined;
|
|
11
|
+
deviceOnlyAvailable: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
const state = useOfflineTileState(offline, deviceOnlyAvailable);
|
|
14
|
+
return <span data-testid="out">{String(state)}</span>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('useOfflineTileState', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: true });
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
cleanup();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is null for a no-tier app while online', () => {
|
|
27
|
+
const { getByTestId } = render(<Harness offline={undefined} deviceOnlyAvailable={false} />);
|
|
28
|
+
expect(getByTestId('out').textContent).toBe('null');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('connectivity-dims a no-tier app while offline', () => {
|
|
32
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
33
|
+
const { getByTestId } = render(<Harness offline={undefined} deviceOnlyAvailable={false} />);
|
|
34
|
+
expect(getByTestId('out').textContent).toBe('connectivity-dimmed');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('never dims an offline-first app, online or offline', () => {
|
|
38
|
+
const online = render(<Harness offline="offline-first" deviceOnlyAvailable={false} />);
|
|
39
|
+
expect(online.getByTestId('out').textContent).toBe('null');
|
|
40
|
+
cleanup();
|
|
41
|
+
|
|
42
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
43
|
+
const offline = render(<Harness offline="offline-first" deviceOnlyAvailable={false} />);
|
|
44
|
+
expect(offline.getByTestId('out').textContent).toBe('null');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('capability-restricts a device-only app when unavailable, regardless of connectivity', () => {
|
|
48
|
+
const online = render(<Harness offline="device-only" deviceOnlyAvailable={false} />);
|
|
49
|
+
expect(online.getByTestId('out').textContent).toBe('capability-restricted');
|
|
50
|
+
cleanup();
|
|
51
|
+
|
|
52
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
53
|
+
const offline = render(<Harness offline="device-only" deviceOnlyAvailable={false} />);
|
|
54
|
+
expect(offline.getByTestId('out').textContent).toBe('capability-restricted');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is not restricted for a device-only app when available, and not dimmed offline either', () => {
|
|
58
|
+
const online = render(<Harness offline="device-only" deviceOnlyAvailable={true} />);
|
|
59
|
+
expect(online.getByTestId('out').textContent).toBe('null');
|
|
60
|
+
cleanup();
|
|
61
|
+
|
|
62
|
+
// Availability is a capability signal, not a connectivity one — a
|
|
63
|
+
// device-only app that IS available behaves like offline-first once the
|
|
64
|
+
// capability question is settled.
|
|
65
|
+
vi.stubGlobal('navigator', { ...window.navigator, onLine: false });
|
|
66
|
+
const offline = render(<Harness offline="device-only" deviceOnlyAvailable={true} />);
|
|
67
|
+
expect(offline.getByTestId('out').textContent).toBe('null');
|
|
68
|
+
});
|
|
69
|
+
});
|