@sovereignfs/ui 0.41.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +49 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/MobileAppsDrawer.module.css +81 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +63 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/PageContainer.module.css +20 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/SwipableMobileCarousel.module.css +55 -0
- package/dist/SwipableMobileCarouselDots.module.css +49 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +1268 -8
- package/dist/index.js +3046 -975
- package/dist/tokens/primitives.css +47 -8
- package/package.json +13 -9
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +31 -9
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/Dialog/Dialog.tsx +8 -59
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +18 -56
- package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +16 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +49 -0
- package/src/components/Markdown/Markdown.stories.tsx +30 -0
- package/src/components/Markdown/Markdown.tsx +168 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +109 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
- package/src/components/MobileHeader/MobileHeader.module.css +63 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +77 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/PageContainer/PageContainer.module.css +20 -0
- package/src/components/PageContainer/PageContainer.tsx +31 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
- package/src/components/PageHeader/PageHeader.tsx +16 -2
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Sheet/Sheet.tsx +9 -51
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/index.ts +18 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +79 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/index.ts +141 -3
- package/src/overlay-shell.ts +94 -0
- package/src/stories/DesignSystemOverview.stories.tsx +936 -3
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/MobilePatterns.stories.tsx +255 -4
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +37 -0
- package/src/stories/PageHeader.stories.tsx +11 -0
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
- package/src/stories/TokenGallery.stories.tsx +177 -16
- package/src/tokens/primitives.css +47 -8
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { ContextMenu } from '../ContextMenu';
|
|
5
|
+
|
|
6
|
+
let isMobileMatches = false;
|
|
7
|
+
|
|
8
|
+
function installMatchMedia() {
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
'matchMedia',
|
|
11
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
12
|
+
matches: isMobileMatches,
|
|
13
|
+
media: query,
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const items = [{ label: 'Delete', onSelect: vi.fn() }];
|
|
24
|
+
|
|
25
|
+
describe('ContextMenu', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
isMobileMatches = false;
|
|
28
|
+
installMatchMedia();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
cleanup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not show the menu before any trigger', () => {
|
|
37
|
+
render(
|
|
38
|
+
<ContextMenu items={items} aria-label="Row actions">
|
|
39
|
+
<div>Row</div>
|
|
40
|
+
</ContextMenu>,
|
|
41
|
+
);
|
|
42
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('opens on right-click (desktop) and prevents the native context menu', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ContextMenu items={items} aria-label="Row actions">
|
|
48
|
+
<div>Row</div>
|
|
49
|
+
</ContextMenu>,
|
|
50
|
+
);
|
|
51
|
+
const target = screen.getByText('Row');
|
|
52
|
+
const event = fireEvent.contextMenu(target, { clientX: 100, clientY: 150 });
|
|
53
|
+
expect(event).toBe(false); // fireEvent returns false when preventDefault was called
|
|
54
|
+
expect(screen.getByRole('menu')).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('selecting an item calls onSelect and closes the menu', () => {
|
|
58
|
+
const onSelect = vi.fn();
|
|
59
|
+
render(
|
|
60
|
+
<ContextMenu items={[{ label: 'Delete', onSelect }]} aria-label="Row actions">
|
|
61
|
+
<div>Row</div>
|
|
62
|
+
</ContextMenu>,
|
|
63
|
+
);
|
|
64
|
+
fireEvent.contextMenu(screen.getByText('Row'), { clientX: 10, clientY: 10 });
|
|
65
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
|
|
66
|
+
expect(onSelect).toHaveBeenCalled();
|
|
67
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('opens via long-press on touch instead of right-click', () => {
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
isMobileMatches = true;
|
|
73
|
+
installMatchMedia();
|
|
74
|
+
render(
|
|
75
|
+
<ContextMenu items={items} aria-label="Row actions">
|
|
76
|
+
<div>Row</div>
|
|
77
|
+
</ContextMenu>,
|
|
78
|
+
);
|
|
79
|
+
const target = screen.getByText('Row');
|
|
80
|
+
fireEvent.pointerDown(target, { pointerType: 'touch', clientX: 10, clientY: 10 });
|
|
81
|
+
act(() => {
|
|
82
|
+
vi.advanceTimersByTime(600);
|
|
83
|
+
});
|
|
84
|
+
expect(screen.getByRole('menu')).toBeDefined();
|
|
85
|
+
vi.useRealTimers();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.sortButton {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--sv-space-1);
|
|
5
|
+
border: none;
|
|
6
|
+
background: transparent;
|
|
7
|
+
padding: 0;
|
|
8
|
+
margin: 0;
|
|
9
|
+
color: inherit;
|
|
10
|
+
font: inherit;
|
|
11
|
+
font-weight: inherit;
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.sortButton:focus-visible {
|
|
16
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
17
|
+
outline-offset: 2px;
|
|
18
|
+
border-radius: var(--sv-radius-sm);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.sortIcon {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
width: var(--sv-icon-size-sm);
|
|
25
|
+
height: var(--sv-icon-size-sm);
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { DataTable, type DataTableColumn } from './DataTable';
|
|
3
|
+
import { StatusBadge } from '../StatusBadge/StatusBadge';
|
|
4
|
+
|
|
5
|
+
interface PluginRow {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
installs: number;
|
|
9
|
+
status: 'synced' | 'draft';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ROWS: PluginRow[] = [
|
|
13
|
+
{ id: 'tasks', name: 'sovereign-tasks', installs: 482, status: 'synced' },
|
|
14
|
+
{ id: 'ledger', name: 'sovereign-ledger', installs: 219, status: 'synced' },
|
|
15
|
+
{ id: 'shopper', name: 'sovereign-shopper', installs: 37, status: 'draft' },
|
|
16
|
+
{ id: 'healthlog', name: 'sovereign-healthlog', installs: 1204, status: 'synced' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const columns: DataTableColumn<PluginRow>[] = [
|
|
20
|
+
{ key: 'name', header: 'Plugin', sortable: true },
|
|
21
|
+
{ key: 'installs', header: 'Installs', sortable: true, align: 'end' },
|
|
22
|
+
{
|
|
23
|
+
key: 'status',
|
|
24
|
+
header: 'Status',
|
|
25
|
+
render: (row) => <StatusBadge status={row.status} />,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
title: 'Components/DataTable',
|
|
31
|
+
component: DataTable,
|
|
32
|
+
parameters: {
|
|
33
|
+
layout: 'padded',
|
|
34
|
+
docs: {
|
|
35
|
+
description: {
|
|
36
|
+
component:
|
|
37
|
+
'Sortable table built on Table. Column-driven: pass columns + data instead of composing TableRow/TableCell by hand. Clicking a sortable header cycles ascending → descending → unsorted.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
args: { columns: [], data: [], getRowKey: (row: PluginRow) => row.id },
|
|
42
|
+
} satisfies Meta<typeof DataTable<PluginRow>>;
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof meta>;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const Default: Story = {
|
|
50
|
+
render: () => <DataTable columns={columns} data={ROWS} getRowKey={(row) => row.id} />,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DefaultSorted: Story = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<DataTable
|
|
56
|
+
columns={columns}
|
|
57
|
+
data={ROWS}
|
|
58
|
+
getRowKey={(row) => row.id}
|
|
59
|
+
defaultSortKey="installs"
|
|
60
|
+
defaultSortDirection="desc"
|
|
61
|
+
/>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Empty: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<DataTable
|
|
68
|
+
columns={columns}
|
|
69
|
+
data={[]}
|
|
70
|
+
getRowKey={(row) => row.id}
|
|
71
|
+
emptyMessage="No plugins installed"
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../Table/Table';
|
|
6
|
+
import { Icon } from '../Icon/Icon';
|
|
7
|
+
import { EmptyState } from '../EmptyState/EmptyState';
|
|
8
|
+
import styles from './DataTable.module.css';
|
|
9
|
+
|
|
10
|
+
export type SortDirection = 'asc' | 'desc';
|
|
11
|
+
|
|
12
|
+
export interface DataTableColumn<T> {
|
|
13
|
+
key: string;
|
|
14
|
+
header: string;
|
|
15
|
+
sortable?: boolean;
|
|
16
|
+
/** Cell content. Defaults to String(row[key]) when omitted. */
|
|
17
|
+
render?: (row: T) => ReactNode;
|
|
18
|
+
/** Value compared when sorting by this column. Defaults to row[key].
|
|
19
|
+
* Required if `key` isn't a property on T (a computed/joined column). */
|
|
20
|
+
sortValue?: (row: T) => string | number;
|
|
21
|
+
align?: 'start' | 'end';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DataTableProps<T> {
|
|
25
|
+
columns: DataTableColumn<T>[];
|
|
26
|
+
data: T[];
|
|
27
|
+
getRowKey: (row: T) => string;
|
|
28
|
+
/** Initial sort — uncontrolled after mount, like Table itself has no
|
|
29
|
+
* externally-controlled state. */
|
|
30
|
+
defaultSortKey?: string;
|
|
31
|
+
defaultSortDirection?: SortDirection;
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compareValues(a: string | number, b: string | number): number {
|
|
37
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
38
|
+
return String(a).localeCompare(String(b));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* DataTable — sortable table built on Table's own primitives (no
|
|
43
|
+
* virtualization or filtering — those stay out of scope, matching Table's
|
|
44
|
+
* own "not a data grid" boundary). Column-driven: pass `columns` + `data`
|
|
45
|
+
* rather than composing `<TableRow>`/`<TableCell>` by hand.
|
|
46
|
+
*
|
|
47
|
+
* Sort state is internal (uncontrolled) and click-cycles
|
|
48
|
+
* ascending → descending → unsorted for a sortable column, mirroring the
|
|
49
|
+
* "toggle → toggle → clear" pattern most sortable tables use rather than
|
|
50
|
+
* being permanently stuck in whichever direction was clicked last.
|
|
51
|
+
*/
|
|
52
|
+
export function DataTable<T>({
|
|
53
|
+
columns,
|
|
54
|
+
data,
|
|
55
|
+
getRowKey,
|
|
56
|
+
defaultSortKey,
|
|
57
|
+
defaultSortDirection = 'asc',
|
|
58
|
+
emptyMessage = 'No results.',
|
|
59
|
+
className,
|
|
60
|
+
}: DataTableProps<T>) {
|
|
61
|
+
const [sortKey, setSortKey] = useState<string | undefined>(defaultSortKey);
|
|
62
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSortDirection);
|
|
63
|
+
|
|
64
|
+
const sortColumn = columns.find((c) => c.key === sortKey);
|
|
65
|
+
|
|
66
|
+
const sortedData = useMemo(() => {
|
|
67
|
+
if (!sortColumn) return data;
|
|
68
|
+
const getValue =
|
|
69
|
+
sortColumn.sortValue ??
|
|
70
|
+
((row: T) => (row as Record<string, unknown>)[sortColumn.key] as string | number);
|
|
71
|
+
const sorted = [...data].sort((a, b) => compareValues(getValue(a), getValue(b)));
|
|
72
|
+
return sortDirection === 'asc' ? sorted : sorted.reverse();
|
|
73
|
+
}, [data, sortColumn, sortDirection]);
|
|
74
|
+
|
|
75
|
+
function handleSort(column: DataTableColumn<T>) {
|
|
76
|
+
if (!column.sortable) return;
|
|
77
|
+
if (sortKey !== column.key) {
|
|
78
|
+
setSortKey(column.key);
|
|
79
|
+
setSortDirection('asc');
|
|
80
|
+
} else if (sortDirection === 'asc') {
|
|
81
|
+
setSortDirection('desc');
|
|
82
|
+
} else {
|
|
83
|
+
setSortKey(undefined);
|
|
84
|
+
setSortDirection('asc');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (data.length === 0) {
|
|
89
|
+
return <EmptyState heading={emptyMessage} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Table className={className}>
|
|
94
|
+
<TableHead>
|
|
95
|
+
<TableRow>
|
|
96
|
+
{columns.map((column) => {
|
|
97
|
+
const isSorted = sortKey === column.key;
|
|
98
|
+
const ariaSort = !column.sortable
|
|
99
|
+
? undefined
|
|
100
|
+
: isSorted
|
|
101
|
+
? sortDirection === 'asc'
|
|
102
|
+
? 'ascending'
|
|
103
|
+
: 'descending'
|
|
104
|
+
: 'none';
|
|
105
|
+
return (
|
|
106
|
+
<TableHeaderCell
|
|
107
|
+
key={column.key}
|
|
108
|
+
aria-sort={ariaSort}
|
|
109
|
+
style={column.align === 'end' ? { textAlign: 'end' } : undefined}
|
|
110
|
+
>
|
|
111
|
+
{column.sortable ? (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className={styles.sortButton}
|
|
115
|
+
onClick={() => handleSort(column)}
|
|
116
|
+
>
|
|
117
|
+
{column.header}
|
|
118
|
+
<span className={styles.sortIcon}>
|
|
119
|
+
{isSorted && (
|
|
120
|
+
<Icon
|
|
121
|
+
name={sortDirection === 'asc' ? 'chevron-up' : 'chevron-down'}
|
|
122
|
+
size="sm"
|
|
123
|
+
aria-hidden
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
</span>
|
|
127
|
+
</button>
|
|
128
|
+
) : (
|
|
129
|
+
column.header
|
|
130
|
+
)}
|
|
131
|
+
</TableHeaderCell>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</TableRow>
|
|
135
|
+
</TableHead>
|
|
136
|
+
<TableBody>
|
|
137
|
+
{sortedData.map((row) => (
|
|
138
|
+
<TableRow key={getRowKey(row)}>
|
|
139
|
+
{columns.map((column) => (
|
|
140
|
+
<TableCell
|
|
141
|
+
key={column.key}
|
|
142
|
+
style={column.align === 'end' ? { textAlign: 'end' } : undefined}
|
|
143
|
+
>
|
|
144
|
+
{column.render
|
|
145
|
+
? column.render(row)
|
|
146
|
+
: String((row as Record<string, unknown>)[column.key] ?? '')}
|
|
147
|
+
</TableCell>
|
|
148
|
+
))}
|
|
149
|
+
</TableRow>
|
|
150
|
+
))}
|
|
151
|
+
</TableBody>
|
|
152
|
+
</Table>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
|
4
|
+
import { DataTable, type DataTableColumn } from '../DataTable';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
interface Plugin {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
installs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const data: Plugin[] = [
|
|
15
|
+
{ id: 'a', name: 'Charlie', installs: 12 },
|
|
16
|
+
{ id: 'b', name: 'Alpha', installs: 30 },
|
|
17
|
+
{ id: 'c', name: 'Bravo', installs: 5 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const columns: DataTableColumn<Plugin>[] = [
|
|
21
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
22
|
+
{ key: 'installs', header: 'Installs', sortable: true, align: 'end' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function rowsText() {
|
|
26
|
+
return screen
|
|
27
|
+
.getAllByRole('row')
|
|
28
|
+
.slice(1)
|
|
29
|
+
.map((row) => within(row).getAllByRole('cell')[0]?.textContent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('DataTable', () => {
|
|
33
|
+
it('renders a row per data item and a header per column', () => {
|
|
34
|
+
render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
|
|
35
|
+
expect(screen.getAllByRole('row')).toHaveLength(4); // 1 header + 3 body rows
|
|
36
|
+
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeDefined();
|
|
37
|
+
expect(screen.getByRole('columnheader', { name: 'Installs' })).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders cell values from row[key] by default', () => {
|
|
41
|
+
render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
|
|
42
|
+
expect(screen.getByText('Charlie')).toBeDefined();
|
|
43
|
+
expect(screen.getByText('30')).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses a custom render function when provided', () => {
|
|
47
|
+
const withRender: DataTableColumn<Plugin>[] = [
|
|
48
|
+
{ key: 'name', header: 'Name' },
|
|
49
|
+
{ key: 'installs', header: 'Installs', render: (row) => `${row.installs} installs` },
|
|
50
|
+
];
|
|
51
|
+
render(<DataTable columns={withRender} data={data} getRowKey={(row) => row.id} />);
|
|
52
|
+
expect(screen.getByText('30 installs')).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shows an empty state instead of a table when data is empty', () => {
|
|
56
|
+
render(
|
|
57
|
+
<DataTable
|
|
58
|
+
columns={columns}
|
|
59
|
+
data={[]}
|
|
60
|
+
getRowKey={(row) => row.id}
|
|
61
|
+
emptyMessage="No plugins"
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
expect(screen.queryByRole('table')).toBeNull();
|
|
65
|
+
expect(screen.getByText('No plugins')).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('non-sortable columns render as plain text, not a button', () => {
|
|
69
|
+
const withPlain: DataTableColumn<Plugin>[] = [{ key: 'name', header: 'Name' }];
|
|
70
|
+
render(<DataTable columns={withPlain} data={data} getRowKey={(row) => row.id} />);
|
|
71
|
+
expect(screen.queryByRole('button', { name: 'Name' })).toBeNull();
|
|
72
|
+
expect(screen.getByRole('columnheader', { name: 'Name' }).getAttribute('aria-sort')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('clicking a sortable header sorts ascending, then descending, then clears', () => {
|
|
76
|
+
render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
|
|
77
|
+
const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
|
|
78
|
+
const button = within(nameHeader).getByRole('button');
|
|
79
|
+
|
|
80
|
+
fireEvent.click(button);
|
|
81
|
+
expect(rowsText()).toEqual(['Alpha', 'Bravo', 'Charlie']);
|
|
82
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
|
|
83
|
+
|
|
84
|
+
fireEvent.click(button);
|
|
85
|
+
expect(rowsText()).toEqual(['Charlie', 'Bravo', 'Alpha']);
|
|
86
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('descending');
|
|
87
|
+
|
|
88
|
+
fireEvent.click(button);
|
|
89
|
+
expect(rowsText()).toEqual(['Charlie', 'Alpha', 'Bravo']);
|
|
90
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('none');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sorts numeric columns numerically, not lexicographically', () => {
|
|
94
|
+
render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
|
|
95
|
+
const installsHeader = screen.getByRole('columnheader', { name: 'Installs' });
|
|
96
|
+
fireEvent.click(within(installsHeader).getByRole('button'));
|
|
97
|
+
expect(rowsText()).toEqual(['Bravo', 'Charlie', 'Alpha']); // 5, 12, 30
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('switching the sort column resets to ascending', () => {
|
|
101
|
+
render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
|
|
102
|
+
const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
|
|
103
|
+
const installsHeader = screen.getByRole('columnheader', { name: 'Installs' });
|
|
104
|
+
fireEvent.click(within(nameHeader).getByRole('button'));
|
|
105
|
+
fireEvent.click(within(nameHeader).getByRole('button')); // now descending
|
|
106
|
+
fireEvent.click(within(installsHeader).getByRole('button'));
|
|
107
|
+
expect(installsHeader.getAttribute('aria-sort')).toBe('ascending');
|
|
108
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('none');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('respects defaultSortKey/defaultSortDirection', () => {
|
|
112
|
+
render(
|
|
113
|
+
<DataTable
|
|
114
|
+
columns={columns}
|
|
115
|
+
data={data}
|
|
116
|
+
getRowKey={(row) => row.id}
|
|
117
|
+
defaultSortKey="installs"
|
|
118
|
+
defaultSortDirection="desc"
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
expect(rowsText()).toEqual(['Alpha', 'Charlie', 'Bravo']); // 30, 12, 5
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/* DatePicker — trigger styled to match Input's height/border/radius/focus
|
|
2
|
-
* ring for visual consistency as a form field.
|
|
2
|
+
* ring for visual consistency as a form field. Kept in sync with Input's
|
|
3
|
+
* own sizing by hand (36px rendered height via explicit height +
|
|
4
|
+
* border-box, not a literal reuse of Input). */
|
|
3
5
|
|
|
4
6
|
.trigger {
|
|
5
7
|
display: flex;
|
|
@@ -7,7 +9,9 @@
|
|
|
7
9
|
justify-content: space-between;
|
|
8
10
|
gap: var(--sv-space-2);
|
|
9
11
|
width: 100%;
|
|
10
|
-
|
|
12
|
+
height: 36px;
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
padding: 4px var(--sv-space-3);
|
|
11
15
|
font-family: var(--sv-font-family);
|
|
12
16
|
font-size: var(--sv-font-size-sm);
|
|
13
17
|
line-height: 1.5;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { EmptyState } from '../EmptyState/EmptyState';
|
|
5
|
+
|
|
6
|
+
export interface DeviceOnlyGateProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Defaults to a generic "this app" phrasing — pass the plugin's own name. */
|
|
9
|
+
surfaceName?: string;
|
|
10
|
+
/**
|
|
11
|
+
* The caller's own `isDeviceOnlyTierAvailable()` result (from
|
|
12
|
+
* `@sovereignfs/sdk/device-client`) — taken as a plain boolean rather than
|
|
13
|
+
* computed inside this component, so `@sovereignfs/ui` never depends on
|
|
14
|
+
* `@sovereignfs/sdk` (this package must stay usable standalone, outside the
|
|
15
|
+
* plugin runtime — RFC 0073).
|
|
16
|
+
*/
|
|
17
|
+
available: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DeviceOnlyGate — blocks a `device-only`-tier plugin's content from
|
|
23
|
+
* rendering on a surface that cannot provide the durable, encrypted,
|
|
24
|
+
* device-auth-gated store that tier requires, showing an explanatory empty
|
|
25
|
+
* state instead of a broken screen (research 0012, epic tasks 2.33 + 3.36).
|
|
26
|
+
*
|
|
27
|
+
* A `device-only` plugin wraps its own root content in this the same way
|
|
28
|
+
* Console and Account wrap theirs in `OfflineGate` — an opt-in pattern each
|
|
29
|
+
* surface applies to itself, not a platform-level route gate. The launcher
|
|
30
|
+
* tile's own "Phone only" badge (`useOfflineTileState`) is advisory UI on top
|
|
31
|
+
* of this; this component is the actual gate, since a user can always reach a
|
|
32
|
+
* route directly (a bookmark, a deep link) without passing through the
|
|
33
|
+
* launcher tile at all.
|
|
34
|
+
*
|
|
35
|
+
* **Not a security boundary.** Availability here is a capability signal, not
|
|
36
|
+
* an authorization check — the real protection for `device-only` data is
|
|
37
|
+
* that it is encrypted and the key requires device auth to release (epic task
|
|
38
|
+
* 1.22), so it stays inaccessible regardless of whether this gate is present,
|
|
39
|
+
* bypassed, or simply never reached (e.g. a hand-crafted request against the
|
|
40
|
+
* plugin's own API routes).
|
|
41
|
+
*/
|
|
42
|
+
export function DeviceOnlyGate({
|
|
43
|
+
children,
|
|
44
|
+
surfaceName,
|
|
45
|
+
available,
|
|
46
|
+
className,
|
|
47
|
+
}: DeviceOnlyGateProps) {
|
|
48
|
+
if (available) return <>{children}</>;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<EmptyState
|
|
52
|
+
icon="smartphone"
|
|
53
|
+
heading="Phone only"
|
|
54
|
+
description={`${surfaceName ?? 'This app'} is only available on a phone with secure storage set up.`}
|
|
55
|
+
className={className}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { DeviceOnlyGate } from '../DeviceOnlyGate';
|
|
5
|
+
|
|
6
|
+
describe('DeviceOnlyGate', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders children unchanged when available', () => {
|
|
10
|
+
render(
|
|
11
|
+
<DeviceOnlyGate available={true}>
|
|
12
|
+
<p>Wallet content</p>
|
|
13
|
+
</DeviceOnlyGate>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByText('Wallet content')).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('replaces children with an explanatory empty state when unavailable', () => {
|
|
19
|
+
render(
|
|
20
|
+
<DeviceOnlyGate available={false}>
|
|
21
|
+
<p>Wallet content</p>
|
|
22
|
+
</DeviceOnlyGate>,
|
|
23
|
+
);
|
|
24
|
+
expect(screen.queryByText('Wallet content')).toBeNull();
|
|
25
|
+
expect(screen.getByText('Phone only')).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('names the surface in the message when provided', () => {
|
|
29
|
+
render(
|
|
30
|
+
<DeviceOnlyGate available={false} surfaceName="Wallet">
|
|
31
|
+
<p>Wallet content</p>
|
|
32
|
+
</DeviceOnlyGate>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByText(/Wallet is only available on a phone/)).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('falls back to generic phrasing with no surface name', () => {
|
|
38
|
+
render(
|
|
39
|
+
<DeviceOnlyGate available={false}>
|
|
40
|
+
<p>Wallet content</p>
|
|
41
|
+
</DeviceOnlyGate>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByText(/This app is only available on a phone/)).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
});
|