@sovereignfs/ui 0.21.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/LICENSE +661 -0
- package/NOTICE +24 -0
- package/dist/Avatar.module.css +38 -0
- package/dist/Badge.module.css +88 -0
- package/dist/Button.module.css +77 -0
- package/dist/Card.module.css +34 -0
- package/dist/Dialog.module.css +188 -0
- package/dist/Drawer.module.css +30 -0
- package/dist/EmptyState.module.css +27 -0
- package/dist/FormField.module.css +28 -0
- package/dist/Icon.module.css +22 -0
- package/dist/Input.module.css +37 -0
- package/dist/NavTabs.module.css +48 -0
- package/dist/PageHeader.module.css +32 -0
- package/dist/Popover.module.css +44 -0
- package/dist/SegmentedControl.module.css +53 -0
- package/dist/Select.module.css +69 -0
- package/dist/Spinner.module.css +39 -0
- package/dist/SystemBanner.module.css +67 -0
- package/dist/Tabs.module.css +49 -0
- package/dist/Toast.module.css +130 -0
- package/dist/Toggle.module.css +52 -0
- package/dist/Tooltip.module.css +51 -0
- package/dist/index.d.ts +327 -0
- package/dist/index.js +1370 -0
- package/dist/tokens/primitives.css +99 -0
- package/dist/tokens/semantic.css +111 -0
- package/dist/tokens.css +5 -0
- package/package.json +50 -0
- package/src/components/Avatar/Avatar.module.css +38 -0
- package/src/components/Avatar/Avatar.tsx +42 -0
- package/src/components/Badge/Badge.module.css +88 -0
- package/src/components/Badge/Badge.stories.tsx +118 -0
- package/src/components/Badge/Badge.tsx +59 -0
- package/src/components/Badge/__tests__/Badge.test.tsx +64 -0
- package/src/components/Button/Button.module.css +77 -0
- package/src/components/Button/Button.stories.tsx +76 -0
- package/src/components/Button/Button.tsx +36 -0
- package/src/components/Button/__tests__/Button.test.tsx +37 -0
- package/src/components/Card/Card.module.css +34 -0
- package/src/components/Card/Card.tsx +34 -0
- package/src/components/Dialog/Dialog.module.css +188 -0
- package/src/components/Dialog/Dialog.stories.tsx +97 -0
- package/src/components/Dialog/Dialog.tsx +137 -0
- package/src/components/Dialog/__tests__/Dialog.test.tsx +73 -0
- package/src/components/Drawer/Drawer.module.css +30 -0
- package/src/components/Drawer/Drawer.stories.tsx +88 -0
- package/src/components/Drawer/Drawer.tsx +103 -0
- package/src/components/Drawer/__tests__/Drawer.test.tsx +56 -0
- package/src/components/EmptyState/EmptyState.module.css +27 -0
- package/src/components/EmptyState/EmptyState.tsx +27 -0
- package/src/components/FormField/FormField.module.css +28 -0
- package/src/components/FormField/FormField.tsx +53 -0
- package/src/components/Icon/Icon.module.css +22 -0
- package/src/components/Icon/Icon.stories.tsx +94 -0
- package/src/components/Icon/Icon.tsx +43 -0
- package/src/components/Icon/__tests__/Icon.test.tsx +40 -0
- package/src/components/Icon/icons/activity.tsx +20 -0
- package/src/components/Icon/icons/alert-triangle.tsx +22 -0
- package/src/components/Icon/icons/bell.tsx +21 -0
- package/src/components/Icon/icons/check.tsx +20 -0
- package/src/components/Icon/icons/chevron-down.tsx +20 -0
- package/src/components/Icon/icons/chevron-left.tsx +20 -0
- package/src/components/Icon/icons/chevron-right.tsx +20 -0
- package/src/components/Icon/icons/chevron-up.tsx +20 -0
- package/src/components/Icon/icons/eye-off.tsx +23 -0
- package/src/components/Icon/icons/eye.tsx +21 -0
- package/src/components/Icon/icons/grid-2x2.tsx +22 -0
- package/src/components/Icon/icons/house.tsx +21 -0
- package/src/components/Icon/icons/index.ts +122 -0
- package/src/components/Icon/icons/info.tsx +22 -0
- package/src/components/Icon/icons/lock.tsx +21 -0
- package/src/components/Icon/icons/log-out.tsx +22 -0
- package/src/components/Icon/icons/mail.tsx +21 -0
- package/src/components/Icon/icons/package.tsx +23 -0
- package/src/components/Icon/icons/pencil.tsx +21 -0
- package/src/components/Icon/icons/plus.tsx +21 -0
- package/src/components/Icon/icons/rotate-ccw.tsx +21 -0
- package/src/components/Icon/icons/search.tsx +21 -0
- package/src/components/Icon/icons/settings.tsx +21 -0
- package/src/components/Icon/icons/shield.tsx +20 -0
- package/src/components/Icon/icons/sliders-horizontal.tsx +28 -0
- package/src/components/Icon/icons/terminal.tsx +19 -0
- package/src/components/Icon/icons/trash-2.tsx +24 -0
- package/src/components/Icon/icons/user.tsx +21 -0
- package/src/components/Icon/icons/x.tsx +21 -0
- package/src/components/Input/Input.module.css +37 -0
- package/src/components/Input/Input.stories.tsx +86 -0
- package/src/components/Input/Input.tsx +14 -0
- package/src/components/Input/__tests__/Input.test.tsx +22 -0
- package/src/components/NavTabs/NavTabs.module.css +48 -0
- package/src/components/NavTabs/NavTabs.tsx +33 -0
- package/src/components/PageHeader/PageHeader.module.css +32 -0
- package/src/components/PageHeader/PageHeader.tsx +21 -0
- package/src/components/Popover/Popover.module.css +44 -0
- package/src/components/Popover/Popover.stories.tsx +232 -0
- package/src/components/Popover/Popover.tsx +75 -0
- package/src/components/Popover/__tests__/Popover.test.tsx +98 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +53 -0
- package/src/components/SegmentedControl/SegmentedControl.stories.tsx +118 -0
- package/src/components/SegmentedControl/SegmentedControl.tsx +56 -0
- package/src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx +63 -0
- package/src/components/Select/Select.module.css +69 -0
- package/src/components/Select/Select.stories.tsx +115 -0
- package/src/components/Select/Select.tsx +40 -0
- package/src/components/Select/__tests__/Select.test.tsx +59 -0
- package/src/components/Spinner/Spinner.module.css +39 -0
- package/src/components/Spinner/Spinner.tsx +23 -0
- package/src/components/SystemBanner/SystemBanner.module.css +67 -0
- package/src/components/SystemBanner/SystemBanner.stories.tsx +86 -0
- package/src/components/SystemBanner/SystemBanner.tsx +50 -0
- package/src/components/SystemBanner/__tests__/SystemBanner.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +49 -0
- package/src/components/Tabs/Tabs.stories.tsx +100 -0
- package/src/components/Tabs/Tabs.tsx +46 -0
- package/src/components/Tabs/__tests__/Tabs.test.tsx +45 -0
- package/src/components/Toast/Toast.module.css +130 -0
- package/src/components/Toast/Toast.stories.tsx +191 -0
- package/src/components/Toast/Toast.tsx +139 -0
- package/src/components/Toggle/Toggle.module.css +52 -0
- package/src/components/Toggle/Toggle.stories.tsx +95 -0
- package/src/components/Toggle/Toggle.tsx +39 -0
- package/src/components/Toggle/__tests__/Toggle.test.tsx +47 -0
- package/src/components/Tooltip/Tooltip.module.css +51 -0
- package/src/components/Tooltip/Tooltip.tsx +30 -0
- package/src/css-modules.d.ts +4 -0
- package/src/index.ts +45 -0
- package/src/stories/Avatar.stories.tsx +44 -0
- package/src/stories/Card.stories.tsx +35 -0
- package/src/stories/DesignSystemOverview.stories.tsx +1683 -0
- package/src/stories/EmptyState.stories.tsx +33 -0
- package/src/stories/FormField.stories.tsx +47 -0
- package/src/stories/MobilePatterns.stories.tsx +1285 -0
- package/src/stories/NavTabs.stories.tsx +35 -0
- package/src/stories/PageHeader.stories.tsx +31 -0
- package/src/stories/Spinner.stories.tsx +25 -0
- package/src/stories/TokenGallery.stories.tsx +355 -0
- package/src/stories/Tooltip.stories.tsx +59 -0
- package/src/tokens/primitives.css +99 -0
- package/src/tokens/semantic.css +111 -0
- package/src/tokens.css +5 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useEffect, useRef } from 'react';
|
|
4
|
+
import styles from './Dialog.module.css';
|
|
5
|
+
|
|
6
|
+
export type DialogSize = 'sm' | 'md' | 'lg' | 'full';
|
|
7
|
+
|
|
8
|
+
export interface DialogProps {
|
|
9
|
+
/** Whether the dialog is shown. When false, nothing renders. */
|
|
10
|
+
open: boolean;
|
|
11
|
+
/** Called on Esc, scrim click, or the close button. */
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
/** Panel size on desktop. Mobile always renders as a full-screen sheet. */
|
|
14
|
+
size?: DialogSize;
|
|
15
|
+
/** Accessible name for the dialog (sets `aria-label` on the panel). */
|
|
16
|
+
'aria-label'?: string;
|
|
17
|
+
/** On mobile: shown in the top bar alongside the close button so the title
|
|
18
|
+
* and dismiss affordance occupy the same row instead of stacking. */
|
|
19
|
+
title?: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FOCUSABLE =
|
|
24
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dialog — a modal surface (scrim + panel) for overlay-shell plugins and any
|
|
28
|
+
* plugin that needs a dismissable layer. Router-agnostic: the caller decides
|
|
29
|
+
* what `onClose` does (the runtime's `@modal` slot wires it to `router.back()`).
|
|
30
|
+
*
|
|
31
|
+
* Behaviour: Esc and scrim-click dismiss; focus moves into the panel on open and
|
|
32
|
+
* is restored to the previously-focused element on close; Tab is trapped within
|
|
33
|
+
* the panel. Styling references `--sv-*` tokens only; on mobile the panel becomes
|
|
34
|
+
* a full-screen sheet regardless of `size`.
|
|
35
|
+
*/
|
|
36
|
+
export function Dialog({
|
|
37
|
+
open,
|
|
38
|
+
onClose,
|
|
39
|
+
size = 'lg',
|
|
40
|
+
'aria-label': ariaLabel,
|
|
41
|
+
title,
|
|
42
|
+
children,
|
|
43
|
+
}: DialogProps) {
|
|
44
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
46
|
+
|
|
47
|
+
// Capture focus on open; restore it on close/unmount.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
51
|
+
const panel = panelRef.current;
|
|
52
|
+
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
53
|
+
(first ?? panel)?.focus();
|
|
54
|
+
return () => previouslyFocused.current?.focus();
|
|
55
|
+
}, [open]);
|
|
56
|
+
|
|
57
|
+
// Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
|
|
58
|
+
// Attached at document level so no keyboard listener is needed on the dialog
|
|
59
|
+
// div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return;
|
|
62
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
63
|
+
if (e.key === 'Escape') {
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
onClose();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (e.key !== 'Tab') return;
|
|
69
|
+
const panel = panelRef.current;
|
|
70
|
+
if (!panel) return;
|
|
71
|
+
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
72
|
+
const first = focusable[0];
|
|
73
|
+
const last = focusable.at(-1);
|
|
74
|
+
if (!first || !last) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const active = document.activeElement;
|
|
79
|
+
if (e.shiftKey && active === first) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
last.focus();
|
|
82
|
+
} else if (!e.shiftKey && active === last) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
first.focus();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
88
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
89
|
+
}, [open, onClose]);
|
|
90
|
+
|
|
91
|
+
if (!open) return null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
95
|
+
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
96
|
+
<div
|
|
97
|
+
className={styles.scrim}
|
|
98
|
+
role="presentation"
|
|
99
|
+
onClick={(e) => {
|
|
100
|
+
if (e.target === e.currentTarget) onClose();
|
|
101
|
+
}}
|
|
102
|
+
onKeyDown={(e) => {
|
|
103
|
+
if (e.key === 'Escape') onClose();
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div
|
|
107
|
+
ref={panelRef}
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-label={ariaLabel ?? title}
|
|
111
|
+
tabIndex={-1}
|
|
112
|
+
className={[styles.panel, styles[size]].join(' ')}
|
|
113
|
+
>
|
|
114
|
+
{/* Mobile: title + close button in one row (hidden on desktop via CSS). */}
|
|
115
|
+
<div className={styles.mobileBar}>
|
|
116
|
+
{title && <span className={styles.mobileBarTitle}>{title}</span>}
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className={styles.mobileBarClose}
|
|
120
|
+
aria-label="Close"
|
|
121
|
+
onClick={onClose}
|
|
122
|
+
>
|
|
123
|
+
×
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
{/* Desktop: absolute close button (hidden on mobile via CSS). */}
|
|
127
|
+
<button type="button" className={styles.close} aria-label="Close" onClick={onClose}>
|
|
128
|
+
×
|
|
129
|
+
</button>
|
|
130
|
+
{/* The panel is a fixed-size box; only this region scrolls, so the
|
|
131
|
+
panel never resizes with its content and the close button stays
|
|
132
|
+
pinned. */}
|
|
133
|
+
<div className={styles.content}>{children}</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Dialog } from '../Dialog';
|
|
5
|
+
|
|
6
|
+
describe('Dialog', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders nothing when closed', () => {
|
|
10
|
+
render(
|
|
11
|
+
<Dialog open={false} onClose={() => {}}>
|
|
12
|
+
Body
|
|
13
|
+
</Dialog>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders the panel with its accessible name when open', () => {
|
|
19
|
+
render(
|
|
20
|
+
<Dialog open onClose={() => {}} aria-label="Settings">
|
|
21
|
+
Body
|
|
22
|
+
</Dialog>,
|
|
23
|
+
);
|
|
24
|
+
const dialog = screen.getByRole('dialog', { name: 'Settings' });
|
|
25
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('calls onClose on Escape', () => {
|
|
29
|
+
const onClose = vi.fn();
|
|
30
|
+
render(
|
|
31
|
+
<Dialog open onClose={onClose}>
|
|
32
|
+
Body
|
|
33
|
+
</Dialog>,
|
|
34
|
+
);
|
|
35
|
+
// Keyboard handler is registered on document (not the dialog element) so
|
|
36
|
+
// screen readers and keyboard users can dismiss from anywhere in the page.
|
|
37
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
38
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('calls onClose on scrim click but not on panel click', () => {
|
|
42
|
+
const onClose = vi.fn();
|
|
43
|
+
render(
|
|
44
|
+
<Dialog open onClose={onClose} aria-label="Panel">
|
|
45
|
+
Body
|
|
46
|
+
</Dialog>,
|
|
47
|
+
);
|
|
48
|
+
fireEvent.click(screen.getByRole('dialog')); // panel — should not close
|
|
49
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
50
|
+
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
|
51
|
+
expect(closeButtons.length).toBeGreaterThan(0);
|
|
52
|
+
fireEvent.click(closeButtons[0] as HTMLElement);
|
|
53
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('applies the size class', () => {
|
|
57
|
+
render(
|
|
58
|
+
<Dialog open onClose={() => {}} size="full" aria-label="Big">
|
|
59
|
+
Body
|
|
60
|
+
</Dialog>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByRole('dialog').className).toContain('full');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('supports the sm size', () => {
|
|
66
|
+
render(
|
|
67
|
+
<Dialog open onClose={() => {}} size="sm" aria-label="Small">
|
|
68
|
+
Body
|
|
69
|
+
</Dialog>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByRole('dialog').className).toContain('sm');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Drawer — bottom-sheet surface (RFC 0013). Shares scrim/shadow tokens with
|
|
2
|
+
* Dialog but slides up from the bottom instead of centering. The panel has no
|
|
3
|
+
* fixed height — it sizes to content, capped at 80dvh so some page context
|
|
4
|
+
* remains visible above. Safe-area-aware: bottom padding clears the home
|
|
5
|
+
* indicator in standalone/fullscreen mode. */
|
|
6
|
+
|
|
7
|
+
.scrim {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
/* When the shell declares --sv-shell-footer-height (mobile nav bar), stop the
|
|
11
|
+
scrim above the footer so the panel never hides behind it. */
|
|
12
|
+
bottom: var(--sv-shell-footer-height, 0);
|
|
13
|
+
z-index: 100;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: flex-end;
|
|
16
|
+
background: var(--sv-color-scrim);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.panel {
|
|
20
|
+
width: 100%;
|
|
21
|
+
max-height: 80dvh;
|
|
22
|
+
overflow-y: auto;
|
|
23
|
+
background: var(--sv-color-surface-raised);
|
|
24
|
+
border-top: 1px solid var(--sv-color-border);
|
|
25
|
+
border-radius: var(--sv-radius-3xl) var(--sv-radius-3xl) 0 0;
|
|
26
|
+
box-shadow: var(--sv-shadow-overlay);
|
|
27
|
+
/* Pull content above the home indicator / bottom bar in standalone mode. */
|
|
28
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
29
|
+
outline: none;
|
|
30
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Button } from '../Button/Button';
|
|
5
|
+
import { Icon } from '../Icon/Icon';
|
|
6
|
+
import { Drawer } from './Drawer';
|
|
7
|
+
|
|
8
|
+
function DrawerDemo({ label = 'Navigation' }: { label?: string }) {
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<Button onClick={() => setOpen(true)}>Open drawer</Button>
|
|
13
|
+
<Drawer open={open} onClose={() => setOpen(false)} aria-label={label}>
|
|
14
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: '8px 0' }}>
|
|
15
|
+
{(['house', 'grid-2x2', 'settings', 'user'] as const).map((icon) => (
|
|
16
|
+
<li key={icon}>
|
|
17
|
+
<button
|
|
18
|
+
onClick={() => setOpen(false)}
|
|
19
|
+
style={{
|
|
20
|
+
display: 'flex',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
gap: 12,
|
|
23
|
+
width: '100%',
|
|
24
|
+
padding: '12px 20px',
|
|
25
|
+
background: 'none',
|
|
26
|
+
border: 'none',
|
|
27
|
+
cursor: 'pointer',
|
|
28
|
+
fontSize: 15,
|
|
29
|
+
color: 'var(--sv-color-text-primary)',
|
|
30
|
+
fontFamily: 'system-ui',
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<Icon name={icon} size="md" aria-hidden />
|
|
34
|
+
{icon.replace(/-/g, ' ')}
|
|
35
|
+
</button>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
</Drawer>
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const meta = {
|
|
45
|
+
title: 'Components/Drawer',
|
|
46
|
+
component: Drawer,
|
|
47
|
+
parameters: {
|
|
48
|
+
layout: 'centered',
|
|
49
|
+
docs: {
|
|
50
|
+
description: {
|
|
51
|
+
component:
|
|
52
|
+
'Dismissable bottom-sheet panel. Used by the mobile shell for plugin navigation. Supports Esc, scrim-click, focus trap. Respects `env(safe-area-inset-bottom)`. Use the viewport addon at 375px to see the intended mobile context.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
viewport: { defaultViewport: 'mobile' },
|
|
56
|
+
},
|
|
57
|
+
} satisfies Meta<typeof Drawer>;
|
|
58
|
+
|
|
59
|
+
export default meta;
|
|
60
|
+
type Story = StoryObj<typeof meta>;
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export const Default: Story = {
|
|
65
|
+
args: { open: false, onClose: () => {}, children: null },
|
|
66
|
+
render: (_args) => <DrawerDemo />,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Closed: Story = {
|
|
70
|
+
args: { open: false, onClose: () => {}, children: null },
|
|
71
|
+
render: (_args) => (
|
|
72
|
+
<Drawer open={false} onClose={() => {}} aria-label="Closed drawer">
|
|
73
|
+
<p>Never seen</p>
|
|
74
|
+
</Drawer>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Play function opens the drawer and asserts its list items are visible. */
|
|
79
|
+
export const OpenViaInteraction: Story = {
|
|
80
|
+
args: { open: false, onClose: () => {}, children: null },
|
|
81
|
+
render: (_args) => <DrawerDemo label="Navigation menu" />,
|
|
82
|
+
play: async ({ canvasElement }) => {
|
|
83
|
+
const canvas = within(canvasElement);
|
|
84
|
+
await userEvent.click(canvas.getByRole('button', { name: /open drawer/i }));
|
|
85
|
+
const drawer = canvas.getByRole('navigation');
|
|
86
|
+
await expect(drawer).toBeVisible();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useEffect, useRef } from 'react';
|
|
4
|
+
import styles from './Drawer.module.css';
|
|
5
|
+
|
|
6
|
+
export interface DrawerProps {
|
|
7
|
+
/** Whether the drawer is shown. When false, nothing renders. */
|
|
8
|
+
open: boolean;
|
|
9
|
+
/** Called on Esc or scrim click. */
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
/** Accessible name for the drawer panel (sets `aria-label`). */
|
|
12
|
+
'aria-label'?: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FOCUSABLE =
|
|
17
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Drawer — a dismissable bottom-sheet panel. Used by the mobile shell for
|
|
21
|
+
* plugin navigation; also available to plugins for any bottom-up surface.
|
|
22
|
+
*
|
|
23
|
+
* Behaviour: Esc and scrim-click dismiss; focus moves to the first focusable
|
|
24
|
+
* element on open and is restored on close; Tab is trapped within the panel.
|
|
25
|
+
* The panel respects `env(safe-area-inset-bottom)` so it clears the home
|
|
26
|
+
* indicator in standalone/fullscreen mode. Shares dismissal conventions and
|
|
27
|
+
* tokens (`--sv-color-scrim`, `--sv-shadow-overlay`) with `Dialog`.
|
|
28
|
+
*/
|
|
29
|
+
export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: DrawerProps) {
|
|
30
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return;
|
|
35
|
+
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
36
|
+
const panel = panelRef.current;
|
|
37
|
+
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
38
|
+
(first ?? panel)?.focus();
|
|
39
|
+
return () => previouslyFocused.current?.focus();
|
|
40
|
+
}, [open]);
|
|
41
|
+
|
|
42
|
+
// Keyboard handling: Escape closes, Tab cycles within the panel (focus trap).
|
|
43
|
+
// Attached at document level so no keyboard listener is needed on the dialog
|
|
44
|
+
// div itself (which would trigger jsx-a11y/no-noninteractive-element-interactions).
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!open) return;
|
|
47
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
48
|
+
if (e.key === 'Escape') {
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
onClose();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (e.key !== 'Tab') return;
|
|
54
|
+
const panel = panelRef.current;
|
|
55
|
+
if (!panel) return;
|
|
56
|
+
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
57
|
+
const first = focusable[0];
|
|
58
|
+
const last = focusable.at(-1);
|
|
59
|
+
if (!first || !last) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const active = document.activeElement;
|
|
64
|
+
if (e.shiftKey && active === first) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
last.focus();
|
|
67
|
+
} else if (!e.shiftKey && active === last) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
first.focus();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
73
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
74
|
+
}, [open, onClose]);
|
|
75
|
+
|
|
76
|
+
if (!open) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
80
|
+
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
81
|
+
<div
|
|
82
|
+
className={styles.scrim}
|
|
83
|
+
role="presentation"
|
|
84
|
+
onClick={(e) => {
|
|
85
|
+
if (e.target === e.currentTarget) onClose();
|
|
86
|
+
}}
|
|
87
|
+
onKeyDown={(e) => {
|
|
88
|
+
if (e.key === 'Escape') onClose();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<div
|
|
92
|
+
ref={panelRef}
|
|
93
|
+
role="dialog"
|
|
94
|
+
aria-modal="true"
|
|
95
|
+
aria-label={ariaLabel}
|
|
96
|
+
tabIndex={-1}
|
|
97
|
+
className={styles.panel}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Drawer } from '../Drawer';
|
|
5
|
+
|
|
6
|
+
describe('Drawer', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders nothing when closed', () => {
|
|
10
|
+
render(
|
|
11
|
+
<Drawer open={false} onClose={() => {}}>
|
|
12
|
+
Body
|
|
13
|
+
</Drawer>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders the panel with its accessible name when open', () => {
|
|
19
|
+
render(
|
|
20
|
+
<Drawer open onClose={() => {}} aria-label="Navigation">
|
|
21
|
+
Body
|
|
22
|
+
</Drawer>,
|
|
23
|
+
);
|
|
24
|
+
const panel = screen.getByRole('dialog', { name: 'Navigation' });
|
|
25
|
+
expect(panel.getAttribute('aria-modal')).toBe('true');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('calls onClose on Escape', () => {
|
|
29
|
+
const onClose = vi.fn();
|
|
30
|
+
render(
|
|
31
|
+
<Drawer open onClose={onClose}>
|
|
32
|
+
Body
|
|
33
|
+
</Drawer>,
|
|
34
|
+
);
|
|
35
|
+
// Keyboard handler is registered on document (not the drawer element) so
|
|
36
|
+
// screen readers and keyboard users can dismiss from anywhere in the page.
|
|
37
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
38
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('calls onClose on scrim click but not on panel click', () => {
|
|
42
|
+
const onClose = vi.fn();
|
|
43
|
+
const { container } = render(
|
|
44
|
+
<Drawer open onClose={onClose} aria-label="Nav">
|
|
45
|
+
<button>Action</button>
|
|
46
|
+
</Drawer>,
|
|
47
|
+
);
|
|
48
|
+
// The scrim is the outermost div; the panel is a child.
|
|
49
|
+
fireEvent.click(screen.getByRole('dialog')); // panel — should not close
|
|
50
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
51
|
+
// Click the scrim background (the container's first child).
|
|
52
|
+
const scrim = container.firstElementChild as HTMLElement;
|
|
53
|
+
fireEvent.click(scrim);
|
|
54
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
gap: var(--sv-space-3);
|
|
7
|
+
padding: var(--sv-space-12) var(--sv-space-6);
|
|
8
|
+
text-align: center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.icon {
|
|
12
|
+
color: var(--sv-color-text-subtle);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.heading {
|
|
16
|
+
margin: 0;
|
|
17
|
+
font-size: var(--sv-font-size-md);
|
|
18
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
19
|
+
color: var(--sv-color-text-primary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.description {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-size: var(--sv-font-size-sm);
|
|
25
|
+
color: var(--sv-color-text-muted);
|
|
26
|
+
max-width: 36ch;
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Icon } from '../Icon/Icon';
|
|
3
|
+
import type { IconName } from '../Icon/Icon';
|
|
4
|
+
import styles from './EmptyState.module.css';
|
|
5
|
+
|
|
6
|
+
export interface EmptyStateProps {
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
heading: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function EmptyState({ icon, heading, description, action, className }: EmptyStateProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
17
|
+
{icon && (
|
|
18
|
+
<span className={styles.icon}>
|
|
19
|
+
<Icon name={icon} size="lg" aria-hidden={true} />
|
|
20
|
+
</span>
|
|
21
|
+
)}
|
|
22
|
+
<h2 className={styles.heading}>{heading}</h2>
|
|
23
|
+
{description && <p className={styles.description}>{description}</p>}
|
|
24
|
+
{action}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.field {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--sv-space-1);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.label {
|
|
8
|
+
font-size: var(--sv-font-size-sm);
|
|
9
|
+
font-weight: var(--sv-font-weight-medium);
|
|
10
|
+
color: var(--sv-color-text-primary);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.required {
|
|
14
|
+
color: var(--sv-color-error-text);
|
|
15
|
+
margin-left: var(--sv-space-1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.hint {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: var(--sv-font-size-xs);
|
|
21
|
+
color: var(--sv-color-text-muted);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.error {
|
|
25
|
+
margin: 0;
|
|
26
|
+
font-size: var(--sv-font-size-xs);
|
|
27
|
+
color: var(--sv-color-error-text);
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
import styles from './FormField.module.css';
|
|
4
|
+
|
|
5
|
+
export interface FormFieldProps {
|
|
6
|
+
label: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
htmlFor?: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FormField({
|
|
16
|
+
label,
|
|
17
|
+
hint,
|
|
18
|
+
error,
|
|
19
|
+
htmlFor,
|
|
20
|
+
required = false,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
}: FormFieldProps) {
|
|
24
|
+
const hintId = useId();
|
|
25
|
+
const errorId = useId();
|
|
26
|
+
const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={[styles.field, className].filter(Boolean).join(' ')}>
|
|
30
|
+
<label className={styles.label} htmlFor={htmlFor}>
|
|
31
|
+
{label}
|
|
32
|
+
{required && (
|
|
33
|
+
<span className={styles.required} aria-hidden="true">
|
|
34
|
+
*
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
</label>
|
|
38
|
+
{/* Clone the child to inject aria-describedby — or just render children
|
|
39
|
+
and let the consumer wire htmlFor/id themselves */}
|
|
40
|
+
<div aria-describedby={describedBy}>{children}</div>
|
|
41
|
+
{hint && !error && (
|
|
42
|
+
<p id={hintId} className={styles.hint}>
|
|
43
|
+
{hint}
|
|
44
|
+
</p>
|
|
45
|
+
)}
|
|
46
|
+
{error && (
|
|
47
|
+
<p id={errorId} className={styles.error} role="alert">
|
|
48
|
+
{error}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
flex-shrink: 0;
|
|
4
|
+
/* Prevent the inline-block from adding line-height whitespace below the SVG. */
|
|
5
|
+
line-height: 0;
|
|
6
|
+
/* Color follows the surrounding text via currentColor — no token needed. */
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.sm {
|
|
10
|
+
width: var(--sv-icon-size-sm);
|
|
11
|
+
height: var(--sv-icon-size-sm);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.md {
|
|
15
|
+
width: var(--sv-icon-size-md);
|
|
16
|
+
height: var(--sv-icon-size-md);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.lg {
|
|
20
|
+
width: var(--sv-icon-size-lg);
|
|
21
|
+
height: var(--sv-icon-size-lg);
|
|
22
|
+
}
|