@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,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { EmptyState } from '../EmptyState/EmptyState';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `'checking'` covers the moment before the caller's own async status check
|
|
8
|
+
* resolves — same transitional-state pattern `EncryptionSection`'s local
|
|
9
|
+
* `LocalState` uses, so this gate never flashes an empty state before the
|
|
10
|
+
* real answer is known. `'no-device-auth'` is the hard-block case (RFC 0093
|
|
11
|
+
* §5, epic task 1.22): the environment supports the tier, but this device
|
|
12
|
+
* has no passcode, fingerprint, or face unlock configured, so a key cannot
|
|
13
|
+
* be created here at all — distinct from `'not-set-up'`, where it can.
|
|
14
|
+
*/
|
|
15
|
+
export type DeviceStorageKeyGateStatus =
|
|
16
|
+
'checking' | 'unsupported' | 'no-device-auth' | 'not-set-up' | 'set-up';
|
|
17
|
+
|
|
18
|
+
export interface DeviceStorageKeyGateProps {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
/** Defaults to a generic "This app" phrasing — pass the plugin's own name. */
|
|
21
|
+
surfaceName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The caller's own `getDeviceStorageKeyStatus()` result (from
|
|
24
|
+
* `@sovereignfs/sdk/device-only-storage`) — taken as a plain status rather
|
|
25
|
+
* than computed inside this component, so `@sovereignfs/ui` never depends
|
|
26
|
+
* on `@sovereignfs/sdk` (same boundary `DeviceOnlyGate`'s `available` prop
|
|
27
|
+
* draws — this package must stay usable standalone, outside the plugin
|
|
28
|
+
* runtime — RFC 0073).
|
|
29
|
+
*/
|
|
30
|
+
status: DeviceStorageKeyGateStatus;
|
|
31
|
+
/**
|
|
32
|
+
* Rendered under the "not set up" message — typically a link to Account →
|
|
33
|
+
* Security. Not built in here: this package depends on no router, so the
|
|
34
|
+
* caller supplies its own navigable element (a plain `<a>`, a framework
|
|
35
|
+
* `<Link>`, whatever the host app uses).
|
|
36
|
+
*/
|
|
37
|
+
setupAction?: ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* DeviceStorageKeyGate — blocks a `device-only`-tier plugin's content until
|
|
43
|
+
* the user has set up their Device Storage Key (RFC 0093 §2, epic task
|
|
44
|
+
* 1.22). Distinct from `DeviceOnlyGate`: that one checks whether the *tier
|
|
45
|
+
* itself* is available on this surface (native shell, secure-storage bridge
|
|
46
|
+
* present); this one checks whether the user has actually completed the
|
|
47
|
+
* one-time, per-device enrollment that tier depends on. A `device-only`
|
|
48
|
+
* plugin typically wants both — `DeviceOnlyGate` outermost (no point
|
|
49
|
+
* checking enrollment on a surface that cannot offer the tier at all), this
|
|
50
|
+
* gate inside it.
|
|
51
|
+
*
|
|
52
|
+
* Enrollment is centralized and decoupled from any single plugin (RFC 0093
|
|
53
|
+
* §2): a plugin that finds no key set up does not run its own enrollment
|
|
54
|
+
* ceremony inline. It stops here and points the user at Account → Security —
|
|
55
|
+
* the one place enrollment happens for every `device-only` plugin on this
|
|
56
|
+
* device — via `setupAction`, the same opt-in, self-applied pattern
|
|
57
|
+
* `OfflineGate`/`DeviceOnlyGate` already use for their own surfaces.
|
|
58
|
+
*/
|
|
59
|
+
export function DeviceStorageKeyGate({
|
|
60
|
+
children,
|
|
61
|
+
surfaceName,
|
|
62
|
+
status,
|
|
63
|
+
setupAction,
|
|
64
|
+
className,
|
|
65
|
+
}: DeviceStorageKeyGateProps) {
|
|
66
|
+
if (status === 'checking') return null;
|
|
67
|
+
if (status === 'set-up') return <>{children}</>;
|
|
68
|
+
|
|
69
|
+
if (status === 'unsupported') {
|
|
70
|
+
return (
|
|
71
|
+
<EmptyState
|
|
72
|
+
icon="smartphone"
|
|
73
|
+
heading="Phone only"
|
|
74
|
+
description={`${surfaceName ?? 'This app'} needs a security feature (WebAuthn PRF and Origin Private File System support) that isn't available here.`}
|
|
75
|
+
className={className}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (status === 'no-device-auth') {
|
|
81
|
+
return (
|
|
82
|
+
<EmptyState
|
|
83
|
+
icon="alert-triangle"
|
|
84
|
+
heading="Set up a device passcode"
|
|
85
|
+
description={`${surfaceName ?? 'This app'} needs a passcode, fingerprint, or face unlock set up on this device before its Device Storage Key can be created. Set one up in your device's system settings, then come back.`}
|
|
86
|
+
className={className}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<EmptyState
|
|
93
|
+
icon="lock"
|
|
94
|
+
heading="Set up your Device Storage Key"
|
|
95
|
+
description={`${surfaceName ?? 'This app'} keeps its data only on this device, protected by a Device Storage Key you set up once in Account → Security.`}
|
|
96
|
+
action={setupAction}
|
|
97
|
+
className={className}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { DeviceStorageKeyGate } from '../DeviceStorageKeyGate';
|
|
5
|
+
|
|
6
|
+
describe('DeviceStorageKeyGate', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders nothing while checking', () => {
|
|
10
|
+
const { container } = render(
|
|
11
|
+
<DeviceStorageKeyGate status="checking">
|
|
12
|
+
<p>Notes content</p>
|
|
13
|
+
</DeviceStorageKeyGate>,
|
|
14
|
+
);
|
|
15
|
+
expect(container.firstChild).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders children unchanged once set up', () => {
|
|
19
|
+
render(
|
|
20
|
+
<DeviceStorageKeyGate status="set-up">
|
|
21
|
+
<p>Notes content</p>
|
|
22
|
+
</DeviceStorageKeyGate>,
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByText('Notes content')).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('shows a "Phone only"-style message when the environment is unsupported', () => {
|
|
28
|
+
render(
|
|
29
|
+
<DeviceStorageKeyGate status="unsupported">
|
|
30
|
+
<p>Notes content</p>
|
|
31
|
+
</DeviceStorageKeyGate>,
|
|
32
|
+
);
|
|
33
|
+
expect(screen.queryByText('Notes content')).toBeNull();
|
|
34
|
+
expect(screen.getByText('Phone only')).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('shows the setup message and prompt when not-set-up', () => {
|
|
38
|
+
render(
|
|
39
|
+
<DeviceStorageKeyGate status="not-set-up">
|
|
40
|
+
<p>Notes content</p>
|
|
41
|
+
</DeviceStorageKeyGate>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.queryByText('Notes content')).toBeNull();
|
|
44
|
+
expect(screen.getByText('Set up your Device Storage Key')).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('names the surface in the not-set-up message when provided', () => {
|
|
48
|
+
render(
|
|
49
|
+
<DeviceStorageKeyGate status="not-set-up" surfaceName="Notes">
|
|
50
|
+
<p>Notes content</p>
|
|
51
|
+
</DeviceStorageKeyGate>,
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByText(/Notes keeps its data only on this device/)).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('falls back to generic phrasing with no surface name', () => {
|
|
57
|
+
render(
|
|
58
|
+
<DeviceStorageKeyGate status="not-set-up">
|
|
59
|
+
<p>Notes content</p>
|
|
60
|
+
</DeviceStorageKeyGate>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByText(/This app keeps its data only on this device/)).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders the caller-supplied setupAction when not-set-up', () => {
|
|
66
|
+
render(
|
|
67
|
+
<DeviceStorageKeyGate
|
|
68
|
+
status="not-set-up"
|
|
69
|
+
setupAction={<a href="/account/security">Go to Account → Security</a>}
|
|
70
|
+
>
|
|
71
|
+
<p>Notes content</p>
|
|
72
|
+
</DeviceStorageKeyGate>,
|
|
73
|
+
);
|
|
74
|
+
expect(screen.getByRole('link', { name: 'Go to Account → Security' })).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders no setupAction when unsupported, even if one is passed', () => {
|
|
78
|
+
render(
|
|
79
|
+
<DeviceStorageKeyGate
|
|
80
|
+
status="unsupported"
|
|
81
|
+
setupAction={<a href="/account/security">Go to Account → Security</a>}
|
|
82
|
+
>
|
|
83
|
+
<p>Notes content</p>
|
|
84
|
+
</DeviceStorageKeyGate>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('shows the hard-block message when no-device-auth', () => {
|
|
90
|
+
render(
|
|
91
|
+
<DeviceStorageKeyGate status="no-device-auth">
|
|
92
|
+
<p>Notes content</p>
|
|
93
|
+
</DeviceStorageKeyGate>,
|
|
94
|
+
);
|
|
95
|
+
expect(screen.queryByText('Notes content')).toBeNull();
|
|
96
|
+
expect(screen.getByText('Set up a device passcode')).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('names the surface in the no-device-auth message when provided', () => {
|
|
100
|
+
render(
|
|
101
|
+
<DeviceStorageKeyGate status="no-device-auth" surfaceName="Notes">
|
|
102
|
+
<p>Notes content</p>
|
|
103
|
+
</DeviceStorageKeyGate>,
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByText(/Notes needs a passcode, fingerprint, or face unlock/)).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders no setupAction when no-device-auth, even if one is passed — fixing this is a device settings change, not a Sovereign link', () => {
|
|
109
|
+
render(
|
|
110
|
+
<DeviceStorageKeyGate
|
|
111
|
+
status="no-device-auth"
|
|
112
|
+
setupAction={<a href="/account/security">Go to Account → Security</a>}
|
|
113
|
+
>
|
|
114
|
+
<p>Notes content</p>
|
|
115
|
+
</DeviceStorageKeyGate>,
|
|
116
|
+
);
|
|
117
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -79,6 +79,9 @@
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/* Sizing audit: reviewed padding/radius against this scale's other
|
|
83
|
+
surfaces. --sv-radius-lg (shared with Card via .panel below) left
|
|
84
|
+
unchanged for the same blast-radius reason as Card.module.css. */
|
|
82
85
|
.content {
|
|
83
86
|
flex: 1 1 auto;
|
|
84
87
|
overflow-y: auto;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
5
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
|
+
import {
|
|
6
|
+
useOverlayFocusCapture,
|
|
7
|
+
useOverlayKeyboardTrap,
|
|
8
|
+
useOverlayScrollLock,
|
|
9
|
+
} from '../../overlay-shell';
|
|
6
10
|
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
7
11
|
import styles from './Dialog.module.css';
|
|
8
12
|
|
|
@@ -69,9 +73,6 @@ export interface DialogProps {
|
|
|
69
73
|
children: ReactNode;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
const FOCUSABLE =
|
|
73
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
74
|
-
|
|
75
76
|
/**
|
|
76
77
|
* Dialog — a modal surface (scrim + panel) for overlay-shell plugins and any
|
|
77
78
|
* plugin that needs a dismissable layer. Router-agnostic: the caller decides
|
|
@@ -97,65 +98,13 @@ export function Dialog({
|
|
|
97
98
|
children,
|
|
98
99
|
}: DialogProps) {
|
|
99
100
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
100
|
-
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
101
101
|
const reducedMotion = usePrefersReducedMotion();
|
|
102
102
|
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
103
103
|
const [secondRow, setSecondRow] = useState<ReactNode | null>(null);
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// so nested overlays (e.g. a confirmation dialog inside an overlay plugin)
|
|
109
|
-
// don't release the lock while a sibling is still open.
|
|
110
|
-
useEffect(() => {
|
|
111
|
-
if (!mounted) return;
|
|
112
|
-
lockBodyScroll();
|
|
113
|
-
return unlockBodyScroll;
|
|
114
|
-
}, [mounted]);
|
|
115
|
-
|
|
116
|
-
// Capture focus on open; restore it on close/unmount.
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
if (!open) return;
|
|
119
|
-
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
120
|
-
const panel = panelRef.current;
|
|
121
|
-
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
122
|
-
(first ?? panel)?.focus();
|
|
123
|
-
return () => previouslyFocused.current?.focus();
|
|
124
|
-
}, [open]);
|
|
125
|
-
|
|
126
|
-
// Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
|
|
127
|
-
// Attached at document level so no keyboard listener is needed on the dialog
|
|
128
|
-
// div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
if (!open) return;
|
|
131
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
132
|
-
if (e.key === 'Escape') {
|
|
133
|
-
e.stopPropagation();
|
|
134
|
-
onClose();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (e.key !== 'Tab') return;
|
|
138
|
-
const panel = panelRef.current;
|
|
139
|
-
if (!panel) return;
|
|
140
|
-
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
141
|
-
const first = focusable[0];
|
|
142
|
-
const last = focusable.at(-1);
|
|
143
|
-
if (!first || !last) {
|
|
144
|
-
e.preventDefault();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const active = document.activeElement;
|
|
148
|
-
if (e.shiftKey && active === first) {
|
|
149
|
-
e.preventDefault();
|
|
150
|
-
last.focus();
|
|
151
|
-
} else if (!e.shiftKey && active === last) {
|
|
152
|
-
e.preventDefault();
|
|
153
|
-
first.focus();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
157
|
-
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
158
|
-
}, [open, onClose]);
|
|
105
|
+
useOverlayScrollLock(mounted);
|
|
106
|
+
useOverlayFocusCapture(panelRef, open);
|
|
107
|
+
useOverlayKeyboardTrap(panelRef, open, onClose);
|
|
159
108
|
|
|
160
109
|
if (!mounted) return null;
|
|
161
110
|
const isOpenPhase = phase === 'open';
|
|
@@ -75,6 +75,50 @@ export const Closed: Story = {
|
|
|
75
75
|
),
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
function DrawerWithTitleDemo() {
|
|
79
|
+
const [open, setOpen] = useState(false);
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<Button onClick={() => setOpen(true)}>Open drawer</Button>
|
|
83
|
+
<Drawer open={open} onClose={() => setOpen(false)} title="Apps">
|
|
84
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: '8px 0' }}>
|
|
85
|
+
{(['house', 'grid-2x2', 'settings', 'user'] as const).map((icon) => (
|
|
86
|
+
<li key={icon}>
|
|
87
|
+
<button
|
|
88
|
+
onClick={() => setOpen(false)}
|
|
89
|
+
style={{
|
|
90
|
+
display: 'flex',
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
gap: 12,
|
|
93
|
+
width: '100%',
|
|
94
|
+
padding: '12px 20px',
|
|
95
|
+
background: 'none',
|
|
96
|
+
border: 'none',
|
|
97
|
+
cursor: 'pointer',
|
|
98
|
+
fontSize: 15,
|
|
99
|
+
color: 'var(--sv-color-text-primary)',
|
|
100
|
+
fontFamily: 'system-ui',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<Icon name={icon} size="md" aria-hidden />
|
|
104
|
+
{icon.replace(/-/g, ' ')}
|
|
105
|
+
</button>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</Drawer>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Opt-in `title` prop renders a built-in `OverlayHeader` (title + close)
|
|
115
|
+
* below the grab handle — hidden by default (see the `Default` story), for
|
|
116
|
+
* consumers that want one instead of building their own header markup. */
|
|
117
|
+
export const WithTitle: Story = {
|
|
118
|
+
args: { open: false, onClose: () => {}, children: null },
|
|
119
|
+
render: (_args) => <DrawerWithTitleDemo />,
|
|
120
|
+
};
|
|
121
|
+
|
|
78
122
|
/** Play function opens the drawer and asserts its list items are visible. */
|
|
79
123
|
export const OpenViaInteraction: Story = {
|
|
80
124
|
args: { open: false, onClose: () => {}, children: null },
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type PointerEvent as ReactPointerEvent, type ReactNode,
|
|
4
|
-
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
3
|
+
import { type PointerEvent as ReactPointerEvent, type ReactNode, useRef } from 'react';
|
|
5
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
|
+
import {
|
|
6
|
+
useOverlayFocusCapture,
|
|
7
|
+
useOverlayKeyboardTrap,
|
|
8
|
+
useOverlayScrollLock,
|
|
9
|
+
} from '../../overlay-shell';
|
|
10
|
+
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
6
11
|
import styles from './Drawer.module.css';
|
|
7
12
|
|
|
8
13
|
// Matches --sv-motion-duration-base (Drawer.module.css) — see Dialog.tsx's
|
|
@@ -22,12 +27,15 @@ export interface DrawerProps {
|
|
|
22
27
|
* "half screen or less" convention for Drawer, this is the only other
|
|
23
28
|
* supported size; taller content should use `Sheet` instead. */
|
|
24
29
|
snapHeight?: 'content' | 'half';
|
|
30
|
+
/** Renders a built-in `OverlayHeader` (title + close) below the grab
|
|
31
|
+
* handle. Omit when the content supplies its own header, or none is
|
|
32
|
+
* needed at all — e.g. the mobile shell's Apps drawer, whose grab handle
|
|
33
|
+
* and swipe-down/scrim-tap dismissal are enough on their own. Mirrors
|
|
34
|
+
* `Sheet`'s identical `title` prop. */
|
|
35
|
+
title?: string;
|
|
25
36
|
children: ReactNode;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
const FOCUSABLE =
|
|
29
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
30
|
-
|
|
31
39
|
// Downward drag distance (px) past which releasing dismisses the drawer
|
|
32
40
|
// instead of snapping back open. Distance-only, no velocity tracking — matches
|
|
33
41
|
// the swipe-to-reveal gesture already established in consuming plugins (see
|
|
@@ -61,10 +69,10 @@ export function Drawer({
|
|
|
61
69
|
onClose,
|
|
62
70
|
'aria-label': ariaLabel,
|
|
63
71
|
snapHeight = 'content',
|
|
72
|
+
title,
|
|
64
73
|
children,
|
|
65
74
|
}: DrawerProps) {
|
|
66
75
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
67
|
-
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
68
76
|
const reducedMotion = usePrefersReducedMotion();
|
|
69
77
|
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
70
78
|
// Tracks an in-progress handle drag. Kept in a ref (not state) so pointermove
|
|
@@ -72,56 +80,9 @@ export function Drawer({
|
|
|
72
80
|
// same technique as the tasks plugin's swipe-to-reveal rows.
|
|
73
81
|
const dragStartY = useRef<number | null>(null);
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!mounted) return;
|
|
79
|
-
lockBodyScroll();
|
|
80
|
-
return unlockBodyScroll;
|
|
81
|
-
}, [mounted]);
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
if (!open) return;
|
|
85
|
-
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
86
|
-
const panel = panelRef.current;
|
|
87
|
-
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
88
|
-
(first ?? panel)?.focus();
|
|
89
|
-
return () => previouslyFocused.current?.focus();
|
|
90
|
-
}, [open]);
|
|
91
|
-
|
|
92
|
-
// Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
|
|
93
|
-
// Attached at document level so no keyboard listener is needed on the dialog
|
|
94
|
-
// div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
if (!open) return;
|
|
97
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
98
|
-
if (e.key === 'Escape') {
|
|
99
|
-
e.stopPropagation();
|
|
100
|
-
onClose();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (e.key !== 'Tab') return;
|
|
104
|
-
const panel = panelRef.current;
|
|
105
|
-
if (!panel) return;
|
|
106
|
-
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
107
|
-
const first = focusable[0];
|
|
108
|
-
const last = focusable.at(-1);
|
|
109
|
-
if (!first || !last) {
|
|
110
|
-
e.preventDefault();
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const active = document.activeElement;
|
|
114
|
-
if (e.shiftKey && active === first) {
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
last.focus();
|
|
117
|
-
} else if (!e.shiftKey && active === last) {
|
|
118
|
-
e.preventDefault();
|
|
119
|
-
first.focus();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
123
|
-
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
124
|
-
}, [open, onClose]);
|
|
83
|
+
useOverlayScrollLock(mounted);
|
|
84
|
+
useOverlayFocusCapture(panelRef, open);
|
|
85
|
+
useOverlayKeyboardTrap(panelRef, open, onClose);
|
|
125
86
|
|
|
126
87
|
// Swipe-down-to-dismiss, initiated only from the grab handle (see the
|
|
127
88
|
// component doc comment for why not the whole panel). Drags the panel
|
|
@@ -207,6 +168,7 @@ export function Drawer({
|
|
|
207
168
|
onPointerUp={(e) => releaseDrag(e, true)}
|
|
208
169
|
onPointerCancel={(e) => releaseDrag(e, false)}
|
|
209
170
|
/>
|
|
171
|
+
{title && <OverlayHeader title={title} onClose={onClose} />}
|
|
210
172
|
{children}
|
|
211
173
|
</div>
|
|
212
174
|
</div>
|
|
@@ -110,6 +110,28 @@ describe('Drawer', () => {
|
|
|
110
110
|
expect(screen.getByRole('dialog').className).not.toMatch(/panelHalf/);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it('does not render OverlayHeader when title is omitted (hidden by default)', () => {
|
|
114
|
+
render(
|
|
115
|
+
<Drawer open onClose={() => {}} aria-label="Nav">
|
|
116
|
+
<button type="button">Own action</button>
|
|
117
|
+
</Drawer>,
|
|
118
|
+
);
|
|
119
|
+
// Only the content's own button — no OverlayHeader close button.
|
|
120
|
+
expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders OverlayHeader (title + close) when title is provided', () => {
|
|
124
|
+
const onClose = vi.fn();
|
|
125
|
+
render(
|
|
126
|
+
<Drawer open onClose={onClose} aria-label="Nav" title="Apps">
|
|
127
|
+
Body
|
|
128
|
+
</Drawer>,
|
|
129
|
+
);
|
|
130
|
+
expect(screen.getByText('Apps')).toBeTruthy();
|
|
131
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
132
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
113
135
|
describe('swipe-down-to-dismiss (grab handle)', () => {
|
|
114
136
|
// The handle is the panel's first child, aria-hidden and unlabeled —
|
|
115
137
|
// same "select via DOM position" approach the scrim-click test above
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { HoverCard } from './HoverCard';
|
|
3
|
+
import { Avatar } from '../Avatar/Avatar';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/HoverCard',
|
|
7
|
+
component: HoverCard,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Hover-triggered popover on desktop, tap-to-toggle on touch. Built on Popover for positioning/collision detection — this only adds hover-intent timing and the touch fallback. Opens on keyboard focus too.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: { trigger: <span />, children: null, 'aria-label': 'Preview' },
|
|
18
|
+
} satisfies Meta<typeof HoverCard>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<HoverCard
|
|
28
|
+
aria-label="User preview"
|
|
29
|
+
trigger={
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
|
33
|
+
>
|
|
34
|
+
<Avatar name="Jane Smith" size="sm" />
|
|
35
|
+
</button>
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
<div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
|
|
39
|
+
<div style={{ fontWeight: 600 }}>Jane Smith</div>
|
|
40
|
+
<div style={{ color: 'var(--sv-color-text-muted)' }}>jane@example.com</div>
|
|
41
|
+
</div>
|
|
42
|
+
</HoverCard>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithLink: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<p style={{ fontSize: 14 }}>
|
|
49
|
+
Assigned to{' '}
|
|
50
|
+
<HoverCard
|
|
51
|
+
aria-label="Assignee preview"
|
|
52
|
+
trigger={
|
|
53
|
+
<a href="/console/users/jane-smith" style={{ color: 'var(--sv-color-text-primary)' }}>
|
|
54
|
+
Jane Smith
|
|
55
|
+
</a>
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
<div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
|
|
59
|
+
<div style={{ fontWeight: 600 }}>Jane Smith</div>
|
|
60
|
+
<div style={{ color: 'var(--sv-color-text-muted)' }}>12 tasks completed this month</div>
|
|
61
|
+
</div>
|
|
62
|
+
</HoverCard>
|
|
63
|
+
.
|
|
64
|
+
</p>
|
|
65
|
+
),
|
|
66
|
+
};
|