@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.
Files changed (141) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +24 -0
  3. package/dist/Avatar.module.css +38 -0
  4. package/dist/Badge.module.css +88 -0
  5. package/dist/Button.module.css +77 -0
  6. package/dist/Card.module.css +34 -0
  7. package/dist/Dialog.module.css +188 -0
  8. package/dist/Drawer.module.css +30 -0
  9. package/dist/EmptyState.module.css +27 -0
  10. package/dist/FormField.module.css +28 -0
  11. package/dist/Icon.module.css +22 -0
  12. package/dist/Input.module.css +37 -0
  13. package/dist/NavTabs.module.css +48 -0
  14. package/dist/PageHeader.module.css +32 -0
  15. package/dist/Popover.module.css +44 -0
  16. package/dist/SegmentedControl.module.css +53 -0
  17. package/dist/Select.module.css +69 -0
  18. package/dist/Spinner.module.css +39 -0
  19. package/dist/SystemBanner.module.css +67 -0
  20. package/dist/Tabs.module.css +49 -0
  21. package/dist/Toast.module.css +130 -0
  22. package/dist/Toggle.module.css +52 -0
  23. package/dist/Tooltip.module.css +51 -0
  24. package/dist/index.d.ts +327 -0
  25. package/dist/index.js +1370 -0
  26. package/dist/tokens/primitives.css +99 -0
  27. package/dist/tokens/semantic.css +111 -0
  28. package/dist/tokens.css +5 -0
  29. package/package.json +50 -0
  30. package/src/components/Avatar/Avatar.module.css +38 -0
  31. package/src/components/Avatar/Avatar.tsx +42 -0
  32. package/src/components/Badge/Badge.module.css +88 -0
  33. package/src/components/Badge/Badge.stories.tsx +118 -0
  34. package/src/components/Badge/Badge.tsx +59 -0
  35. package/src/components/Badge/__tests__/Badge.test.tsx +64 -0
  36. package/src/components/Button/Button.module.css +77 -0
  37. package/src/components/Button/Button.stories.tsx +76 -0
  38. package/src/components/Button/Button.tsx +36 -0
  39. package/src/components/Button/__tests__/Button.test.tsx +37 -0
  40. package/src/components/Card/Card.module.css +34 -0
  41. package/src/components/Card/Card.tsx +34 -0
  42. package/src/components/Dialog/Dialog.module.css +188 -0
  43. package/src/components/Dialog/Dialog.stories.tsx +97 -0
  44. package/src/components/Dialog/Dialog.tsx +137 -0
  45. package/src/components/Dialog/__tests__/Dialog.test.tsx +73 -0
  46. package/src/components/Drawer/Drawer.module.css +30 -0
  47. package/src/components/Drawer/Drawer.stories.tsx +88 -0
  48. package/src/components/Drawer/Drawer.tsx +103 -0
  49. package/src/components/Drawer/__tests__/Drawer.test.tsx +56 -0
  50. package/src/components/EmptyState/EmptyState.module.css +27 -0
  51. package/src/components/EmptyState/EmptyState.tsx +27 -0
  52. package/src/components/FormField/FormField.module.css +28 -0
  53. package/src/components/FormField/FormField.tsx +53 -0
  54. package/src/components/Icon/Icon.module.css +22 -0
  55. package/src/components/Icon/Icon.stories.tsx +94 -0
  56. package/src/components/Icon/Icon.tsx +43 -0
  57. package/src/components/Icon/__tests__/Icon.test.tsx +40 -0
  58. package/src/components/Icon/icons/activity.tsx +20 -0
  59. package/src/components/Icon/icons/alert-triangle.tsx +22 -0
  60. package/src/components/Icon/icons/bell.tsx +21 -0
  61. package/src/components/Icon/icons/check.tsx +20 -0
  62. package/src/components/Icon/icons/chevron-down.tsx +20 -0
  63. package/src/components/Icon/icons/chevron-left.tsx +20 -0
  64. package/src/components/Icon/icons/chevron-right.tsx +20 -0
  65. package/src/components/Icon/icons/chevron-up.tsx +20 -0
  66. package/src/components/Icon/icons/eye-off.tsx +23 -0
  67. package/src/components/Icon/icons/eye.tsx +21 -0
  68. package/src/components/Icon/icons/grid-2x2.tsx +22 -0
  69. package/src/components/Icon/icons/house.tsx +21 -0
  70. package/src/components/Icon/icons/index.ts +122 -0
  71. package/src/components/Icon/icons/info.tsx +22 -0
  72. package/src/components/Icon/icons/lock.tsx +21 -0
  73. package/src/components/Icon/icons/log-out.tsx +22 -0
  74. package/src/components/Icon/icons/mail.tsx +21 -0
  75. package/src/components/Icon/icons/package.tsx +23 -0
  76. package/src/components/Icon/icons/pencil.tsx +21 -0
  77. package/src/components/Icon/icons/plus.tsx +21 -0
  78. package/src/components/Icon/icons/rotate-ccw.tsx +21 -0
  79. package/src/components/Icon/icons/search.tsx +21 -0
  80. package/src/components/Icon/icons/settings.tsx +21 -0
  81. package/src/components/Icon/icons/shield.tsx +20 -0
  82. package/src/components/Icon/icons/sliders-horizontal.tsx +28 -0
  83. package/src/components/Icon/icons/terminal.tsx +19 -0
  84. package/src/components/Icon/icons/trash-2.tsx +24 -0
  85. package/src/components/Icon/icons/user.tsx +21 -0
  86. package/src/components/Icon/icons/x.tsx +21 -0
  87. package/src/components/Input/Input.module.css +37 -0
  88. package/src/components/Input/Input.stories.tsx +86 -0
  89. package/src/components/Input/Input.tsx +14 -0
  90. package/src/components/Input/__tests__/Input.test.tsx +22 -0
  91. package/src/components/NavTabs/NavTabs.module.css +48 -0
  92. package/src/components/NavTabs/NavTabs.tsx +33 -0
  93. package/src/components/PageHeader/PageHeader.module.css +32 -0
  94. package/src/components/PageHeader/PageHeader.tsx +21 -0
  95. package/src/components/Popover/Popover.module.css +44 -0
  96. package/src/components/Popover/Popover.stories.tsx +232 -0
  97. package/src/components/Popover/Popover.tsx +75 -0
  98. package/src/components/Popover/__tests__/Popover.test.tsx +98 -0
  99. package/src/components/SegmentedControl/SegmentedControl.module.css +53 -0
  100. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +118 -0
  101. package/src/components/SegmentedControl/SegmentedControl.tsx +56 -0
  102. package/src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx +63 -0
  103. package/src/components/Select/Select.module.css +69 -0
  104. package/src/components/Select/Select.stories.tsx +115 -0
  105. package/src/components/Select/Select.tsx +40 -0
  106. package/src/components/Select/__tests__/Select.test.tsx +59 -0
  107. package/src/components/Spinner/Spinner.module.css +39 -0
  108. package/src/components/Spinner/Spinner.tsx +23 -0
  109. package/src/components/SystemBanner/SystemBanner.module.css +67 -0
  110. package/src/components/SystemBanner/SystemBanner.stories.tsx +86 -0
  111. package/src/components/SystemBanner/SystemBanner.tsx +50 -0
  112. package/src/components/SystemBanner/__tests__/SystemBanner.test.tsx +50 -0
  113. package/src/components/Tabs/Tabs.module.css +49 -0
  114. package/src/components/Tabs/Tabs.stories.tsx +100 -0
  115. package/src/components/Tabs/Tabs.tsx +46 -0
  116. package/src/components/Tabs/__tests__/Tabs.test.tsx +45 -0
  117. package/src/components/Toast/Toast.module.css +130 -0
  118. package/src/components/Toast/Toast.stories.tsx +191 -0
  119. package/src/components/Toast/Toast.tsx +139 -0
  120. package/src/components/Toggle/Toggle.module.css +52 -0
  121. package/src/components/Toggle/Toggle.stories.tsx +95 -0
  122. package/src/components/Toggle/Toggle.tsx +39 -0
  123. package/src/components/Toggle/__tests__/Toggle.test.tsx +47 -0
  124. package/src/components/Tooltip/Tooltip.module.css +51 -0
  125. package/src/components/Tooltip/Tooltip.tsx +30 -0
  126. package/src/css-modules.d.ts +4 -0
  127. package/src/index.ts +45 -0
  128. package/src/stories/Avatar.stories.tsx +44 -0
  129. package/src/stories/Card.stories.tsx +35 -0
  130. package/src/stories/DesignSystemOverview.stories.tsx +1683 -0
  131. package/src/stories/EmptyState.stories.tsx +33 -0
  132. package/src/stories/FormField.stories.tsx +47 -0
  133. package/src/stories/MobilePatterns.stories.tsx +1285 -0
  134. package/src/stories/NavTabs.stories.tsx +35 -0
  135. package/src/stories/PageHeader.stories.tsx +31 -0
  136. package/src/stories/Spinner.stories.tsx +25 -0
  137. package/src/stories/TokenGallery.stories.tsx +355 -0
  138. package/src/stories/Tooltip.stories.tsx +59 -0
  139. package/src/tokens/primitives.css +99 -0
  140. package/src/tokens/semantic.css +111 -0
  141. 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
+ }