@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,77 @@
1
+ .button {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ gap: var(--sv-space-2);
6
+ border: 1px solid transparent;
7
+ border-radius: var(--sv-radius-md);
8
+ font-family: var(--sv-font-family);
9
+ font-weight: var(--sv-font-weight-medium);
10
+ line-height: 1;
11
+ cursor: pointer;
12
+ transition:
13
+ background-color 0.15s ease,
14
+ border-color 0.15s ease,
15
+ color 0.15s ease;
16
+ }
17
+
18
+ .button:focus-visible {
19
+ outline: 2px solid var(--sv-color-focus-ring);
20
+ outline-offset: 2px;
21
+ }
22
+
23
+ .button:disabled {
24
+ opacity: 0.5;
25
+ cursor: not-allowed;
26
+ }
27
+
28
+ /* Sizes */
29
+ .sm {
30
+ padding: var(--sv-space-1) var(--sv-space-3);
31
+ font-size: var(--sv-font-size-sm);
32
+ }
33
+
34
+ .md {
35
+ padding: 13px var(--sv-space-4);
36
+ font-size: var(--sv-font-size-sm);
37
+ font-weight: var(--sv-font-weight-semibold);
38
+ }
39
+
40
+ /* Variants */
41
+ .primary {
42
+ background-color: var(--sv-color-accent);
43
+ color: var(--sv-color-text-on-accent);
44
+ }
45
+
46
+ .primary:hover:not(:disabled) {
47
+ background-color: var(--sv-color-accent-hover);
48
+ }
49
+
50
+ .secondary {
51
+ background-color: var(--sv-color-surface);
52
+ color: var(--sv-color-text-primary);
53
+ border-color: var(--sv-color-border-strong);
54
+ }
55
+
56
+ .secondary:hover:not(:disabled) {
57
+ background-color: var(--sv-color-surface-sunken);
58
+ }
59
+
60
+ .ghost {
61
+ background-color: transparent;
62
+ color: var(--sv-color-text-primary);
63
+ }
64
+
65
+ .ghost:hover:not(:disabled) {
66
+ background-color: var(--sv-color-surface-sunken);
67
+ }
68
+
69
+ .destructive {
70
+ background-color: transparent;
71
+ color: var(--sv-color-error-text);
72
+ border-color: var(--sv-color-error-border);
73
+ }
74
+
75
+ .destructive:hover:not(:disabled) {
76
+ background-color: var(--sv-color-error-surface);
77
+ }
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Icon } from '../Icon/Icon';
3
+ import { Button } from './Button';
4
+
5
+ const meta = {
6
+ title: 'Components/Button',
7
+ component: Button,
8
+ parameters: {
9
+ layout: 'centered',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'The primitive interactive control. RSC-safe (no state, forwards props). All styling references `--sv-*` tokens; no hardcoded values.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'destructive'] },
19
+ size: { control: 'select', options: ['sm', 'md'] },
20
+ disabled: { control: 'boolean' },
21
+ children: { control: 'text' },
22
+ },
23
+ args: {
24
+ children: 'Button',
25
+ },
26
+ } satisfies Meta<typeof Button>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const Primary: Story = { args: { variant: 'primary' } };
34
+ export const Secondary: Story = { args: { variant: 'secondary' } };
35
+ export const Ghost: Story = { args: { variant: 'ghost' } };
36
+ export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete account' } };
37
+
38
+ export const Small: Story = { args: { size: 'sm' } };
39
+ export const Medium: Story = { args: { size: 'md' } };
40
+
41
+ export const Disabled: Story = { args: { disabled: true } };
42
+ export const DisabledSecondary: Story = { args: { variant: 'secondary', disabled: true } };
43
+
44
+ export const WithLeadingIcon: Story = {
45
+ args: {
46
+ children: (
47
+ <>
48
+ <Icon name="plus" size="sm" aria-hidden />
49
+ Add item
50
+ </>
51
+ ),
52
+ },
53
+ };
54
+
55
+ export const IconOnly: Story = {
56
+ args: {
57
+ variant: 'ghost',
58
+ 'aria-label': 'Settings',
59
+ children: <Icon name="settings" size="sm" aria-hidden />,
60
+ },
61
+ };
62
+
63
+ /** All variant × size combinations at a glance. */
64
+ export const AllVariants: Story = {
65
+ render: (_args) => (
66
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'flex-start' }}>
67
+ {(['primary', 'secondary', 'ghost', 'destructive'] as const).map((v) =>
68
+ (['md', 'sm'] as const).map((s) => (
69
+ <Button key={`${v}-${s}`} variant={v} size={s}>
70
+ {v} / {s}
71
+ </Button>
72
+ )),
73
+ )}
74
+ </div>
75
+ ),
76
+ };
@@ -0,0 +1,36 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+ import styles from './Button.module.css';
3
+
4
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
5
+ export type ButtonSize = 'sm' | 'md';
6
+
7
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8
+ variant?: ButtonVariant;
9
+ size?: ButtonSize;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * Button — the primitive interactive control. Presentational and RSC-safe: it
15
+ * holds no state and simply forwards props to the underlying `<button>`, so it
16
+ * renders in both Server and Client Components. All styling references `--sv-*`
17
+ * tokens via CSS Modules; there are no hardcoded values.
18
+ */
19
+ export function Button({
20
+ variant = 'primary',
21
+ size = 'md',
22
+ type = 'button',
23
+ className,
24
+ children,
25
+ ...rest
26
+ }: ButtonProps) {
27
+ const classes = [styles.button, styles[variant], styles[size], className]
28
+ .filter(Boolean)
29
+ .join(' ');
30
+
31
+ return (
32
+ <button type={type} className={classes} {...rest}>
33
+ {children}
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { Button } from '../Button';
5
+
6
+ describe('Button', () => {
7
+ it('renders its children', () => {
8
+ render(<Button>Click me</Button>);
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeDefined();
10
+ });
11
+
12
+ it('applies the variant and size classes', () => {
13
+ render(
14
+ <Button variant="ghost" size="sm">
15
+ Go
16
+ </Button>,
17
+ );
18
+ const button = screen.getByRole('button', { name: 'Go' });
19
+ expect(button.className).toContain('ghost');
20
+ expect(button.className).toContain('sm');
21
+ });
22
+
23
+ it('defaults to type="button"', () => {
24
+ render(<Button>Save</Button>);
25
+ expect(screen.getByRole('button', { name: 'Save' }).getAttribute('type')).toBe('button');
26
+ });
27
+
28
+ it('forwards native button props', () => {
29
+ render(<Button disabled>Nope</Button>);
30
+ expect((screen.getByRole('button', { name: 'Nope' }) as HTMLButtonElement).disabled).toBe(true);
31
+ });
32
+
33
+ it('applies destructive variant class', () => {
34
+ render(<Button variant="destructive">Delete</Button>);
35
+ expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('destructive');
36
+ });
37
+ });
@@ -0,0 +1,34 @@
1
+ .card {
2
+ background: var(--sv-color-surface-raised);
3
+ border: 1px solid var(--sv-color-border);
4
+ border-radius: var(--sv-radius-lg);
5
+ box-shadow: var(--sv-shadow-card);
6
+ }
7
+
8
+ .paddingSm {
9
+ padding: var(--sv-space-3);
10
+ }
11
+
12
+ .paddingMd {
13
+ padding: var(--sv-space-5);
14
+ }
15
+
16
+ .paddingLg {
17
+ padding: var(--sv-space-8);
18
+ }
19
+
20
+ .interactive {
21
+ cursor: pointer;
22
+ text-decoration: none;
23
+ display: block;
24
+ transition: border-color 0.15s ease;
25
+ }
26
+
27
+ .interactive:hover {
28
+ border-color: var(--sv-color-border-strong);
29
+ }
30
+
31
+ .interactive:focus-visible {
32
+ outline: 2px solid var(--sv-color-focus-ring);
33
+ outline-offset: 2px;
34
+ }
@@ -0,0 +1,34 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ import styles from './Card.module.css';
3
+
4
+ export interface CardProps extends HTMLAttributes<HTMLElement> {
5
+ as?: 'div' | 'article' | 'li';
6
+ interactive?: boolean;
7
+ padding?: 'sm' | 'md' | 'lg';
8
+ children: ReactNode;
9
+ }
10
+
11
+ const paddingClass: Record<NonNullable<CardProps['padding']>, string> = {
12
+ sm: styles.paddingSm as string,
13
+ md: styles.paddingMd as string,
14
+ lg: styles.paddingLg as string,
15
+ };
16
+
17
+ export function Card({
18
+ as: Tag = 'div',
19
+ interactive = false,
20
+ padding = 'md',
21
+ className,
22
+ children,
23
+ ...rest
24
+ }: CardProps) {
25
+ const cls = [styles.card, paddingClass[padding], interactive && styles.interactive, className]
26
+ .filter(Boolean)
27
+ .join(' ');
28
+
29
+ return (
30
+ <Tag className={cls} {...rest}>
31
+ {children}
32
+ </Tag>
33
+ );
34
+ }
@@ -0,0 +1,188 @@
1
+ /* Dialog — scrim + centered panel. Tokens only (z-index is structural, not a
2
+ * theming value, so it has no token).
3
+ *
4
+ * Each size is a FIXED box (width AND height) — the panel never grows or
5
+ * shrinks with its content, so switching views inside it (e.g. overlay-plugin
6
+ * tabs) keeps the dialog the exact same size. Overflow scrolls inside `.content`
7
+ * while the panel and its close button stay put. The scrim's padding is the
8
+ * fixed margin `lg` leaves around the viewport; `sm`/`md` are centered and cap
9
+ * to that available space on short screens. On mobile every size becomes a
10
+ * full-screen sheet.
11
+ *
12
+ * `--sv-dialog-inset-left` lets a host inset the scrim past a fixed left nav
13
+ * rail (e.g. the platform sidebar) so the overlay's left margin is measured
14
+ * from the rail, not the viewport edge, and the rail stays visible/usable.
15
+ * `--sv-dialog-inset-top` (RFC 0013) mirrors this on the vertical axis: the
16
+ * shell sets it to the mobile header height so the header stays visible and
17
+ * interactive above the dialog sheet. Defaults to 0 for standalone use. */
18
+
19
+ .scrim {
20
+ position: fixed;
21
+ top: var(--sv-dialog-inset-top, 0);
22
+ right: 0;
23
+ bottom: 0;
24
+ left: 0;
25
+ z-index: 100;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ /* left edge always 0 so the backdrop covers the full viewport incl. sidebar.
30
+ padding-left offsets the panel's available area to start at the rail edge. */
31
+ padding: var(--sv-space-8);
32
+ padding-left: calc(var(--sv-dialog-inset-left, 0px) + var(--sv-space-8));
33
+ background: var(--sv-color-scrim);
34
+ }
35
+
36
+ .panel {
37
+ position: relative;
38
+ display: flex;
39
+ flex-direction: column;
40
+ overflow: hidden;
41
+ background: var(--sv-color-surface-raised);
42
+ color: var(--sv-color-text-primary);
43
+ border: 1px solid var(--sv-color-border);
44
+ border-radius: var(--sv-radius-lg);
45
+ box-shadow: var(--sv-shadow-overlay);
46
+ }
47
+
48
+ .content {
49
+ flex: 1 1 auto;
50
+ overflow-y: auto;
51
+ padding: var(--sv-space-6);
52
+ }
53
+
54
+ /* Fixed sizes. `max-*: 100%` caps to the scrim's content box (viewport minus
55
+ * margin) so a size never overflows a small viewport. */
56
+ .sm {
57
+ width: 24rem;
58
+ height: 28rem;
59
+ max-width: 100%;
60
+ max-height: 100%;
61
+ }
62
+
63
+ .md {
64
+ width: 36rem;
65
+ height: 42rem;
66
+ max-width: 100%;
67
+ max-height: 100%;
68
+ }
69
+
70
+ .lg,
71
+ .full {
72
+ width: 100%;
73
+ height: 100%;
74
+ }
75
+
76
+ .close {
77
+ position: absolute;
78
+ top: var(--sv-space-3);
79
+ right: var(--sv-space-3);
80
+ z-index: 1;
81
+ display: inline-flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ width: var(--sv-space-8);
85
+ height: var(--sv-space-8);
86
+ border: none;
87
+ border-radius: var(--sv-radius-md);
88
+ background: transparent;
89
+ color: var(--sv-color-text-muted);
90
+ font-size: var(--sv-font-size-xl);
91
+ line-height: 1;
92
+ cursor: pointer;
93
+ }
94
+
95
+ .close:hover {
96
+ background: var(--sv-color-surface-sunken);
97
+ color: var(--sv-color-text-primary);
98
+ }
99
+
100
+ .close:focus-visible {
101
+ outline: 2px solid var(--sv-color-focus-ring);
102
+ outline-offset: 2px;
103
+ }
104
+
105
+ /* Hidden on desktop; shown only on mobile where it replaces the close button. */
106
+ .mobileBar {
107
+ display: none;
108
+ }
109
+
110
+ /* Mobile: full-screen sheet (RFC 0001 §3, breakpoint unified to 768px per
111
+ * RFC 0013). Every size fills the scrim's available area (which is already
112
+ * inset from the top by --sv-dialog-inset-top when a fixed header is shown).
113
+ * The panel therefore fills height: 100% of the remaining space, not 100dvh. */
114
+ @media (max-width: 768px) {
115
+ /* On mobile the overlay feels like a full-page push, not a floating modal.
116
+ Remove the dim scrim and panel chrome so it's indistinguishable from a
117
+ regular page render. router.back() still closes it correctly. */
118
+ .scrim {
119
+ padding: 0;
120
+ background: transparent;
121
+ }
122
+
123
+ .panel,
124
+ .sm,
125
+ .md,
126
+ .lg,
127
+ .full {
128
+ width: 100%;
129
+ height: 100%;
130
+ max-width: none;
131
+ max-height: none;
132
+ border: none;
133
+ border-radius: 0;
134
+ box-shadow: none;
135
+ background: var(--sv-color-surface);
136
+ }
137
+
138
+ /* Desktop close button is replaced by mobileBar on mobile. */
139
+ .close {
140
+ display: none;
141
+ }
142
+
143
+ /* Title + close button in one solid row. Being a flex sibling before
144
+ .content means it never scrolls — the content area scrolls below it. */
145
+ .mobileBar {
146
+ display: flex;
147
+ align-items: center;
148
+ flex-shrink: 0;
149
+ padding: var(--sv-space-3) var(--sv-space-4);
150
+ background: var(--sv-color-surface);
151
+ border-bottom: 1px solid var(--sv-color-border);
152
+ }
153
+
154
+ .mobileBarTitle {
155
+ flex: 1;
156
+ font-size: var(--sv-font-size-xl);
157
+ font-weight: var(--sv-font-weight-semibold);
158
+ color: var(--sv-color-text-primary);
159
+ }
160
+
161
+ .mobileBarClose {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ width: var(--sv-space-8);
166
+ height: var(--sv-space-8);
167
+ flex-shrink: 0;
168
+ border: none;
169
+ border-radius: var(--sv-radius-md);
170
+ background: transparent;
171
+ color: var(--sv-color-text-muted);
172
+ font-size: var(--sv-font-size-xl);
173
+ line-height: 1;
174
+ cursor: pointer;
175
+ }
176
+
177
+ .mobileBarClose:hover {
178
+ background: var(--sv-color-surface-sunken);
179
+ color: var(--sv-color-text-primary);
180
+ }
181
+
182
+ /* Remove top padding so the sticky plugin header sits flush against the
183
+ mobileBar — no gap for scrolling content to bleed through. */
184
+ .content {
185
+ padding-top: 0;
186
+ padding-bottom: calc(var(--sv-shell-footer-height, 60px) + var(--sv-space-6, 24px));
187
+ }
188
+ }
@@ -0,0 +1,97 @@
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 { Dialog } from './Dialog';
6
+
7
+ // Controlled wrapper so the play function can open/inspect the dialog.
8
+ function DialogDemo({
9
+ size = 'lg',
10
+ label = 'Example dialog',
11
+ }: {
12
+ size?: 'sm' | 'md' | 'lg' | 'full';
13
+ label?: string;
14
+ }) {
15
+ const [open, setOpen] = useState(false);
16
+ return (
17
+ <>
18
+ <Button onClick={() => setOpen(true)}>Open {size} dialog</Button>
19
+ <Dialog open={open} onClose={() => setOpen(false)} size={size} aria-label={label}>
20
+ <div style={{ padding: 24, fontFamily: 'system-ui' }}>
21
+ <h2
22
+ style={{
23
+ fontSize: 18,
24
+ fontWeight: 600,
25
+ color: 'var(--sv-color-text-primary)',
26
+ marginBottom: 12,
27
+ }}
28
+ >
29
+ {label}
30
+ </h2>
31
+ <p style={{ color: 'var(--sv-color-text-muted)', marginBottom: 24 }}>
32
+ This is a <strong>{size}</strong> dialog. Press Esc or click the scrim to dismiss.
33
+ </p>
34
+ <Button variant="secondary" onClick={() => setOpen(false)}>
35
+ Close
36
+ </Button>
37
+ </div>
38
+ </Dialog>
39
+ </>
40
+ );
41
+ }
42
+
43
+ const meta = {
44
+ title: 'Components/Dialog',
45
+ component: Dialog,
46
+ parameters: {
47
+ layout: 'centered',
48
+ docs: {
49
+ description: {
50
+ component:
51
+ 'Modal surface (scrim + panel). Router-agnostic — caller provides `onClose`. Supports Esc, scrim-click, focus trap and focus restoration. Sizes: `sm` / `md` / `lg` / `full`. Mobile always renders as a full-screen sheet.',
52
+ },
53
+ },
54
+ },
55
+ } satisfies Meta<typeof Dialog>;
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof meta>;
59
+
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export const Small: Story = {
63
+ args: { open: false, onClose: () => {}, children: null },
64
+ render: (_args) => <DialogDemo size="sm" label="Small dialog" />,
65
+ };
66
+
67
+ export const Medium: Story = {
68
+ args: { open: false, onClose: () => {}, children: null },
69
+ render: (_args) => <DialogDemo size="md" label="Medium dialog" />,
70
+ };
71
+
72
+ export const Large: Story = {
73
+ args: { open: false, onClose: () => {}, children: null },
74
+ render: (_args) => <DialogDemo size="lg" label="Large dialog" />,
75
+ };
76
+
77
+ export const Closed: Story = {
78
+ args: { open: false, onClose: () => {}, children: null },
79
+ render: (_args) => (
80
+ <Dialog open={false} onClose={() => {}} aria-label="Closed dialog">
81
+ <p>Never seen</p>
82
+ </Dialog>
83
+ ),
84
+ };
85
+
86
+ /** Play function opens the dialog and asserts it is visible. */
87
+ export const OpenViaInteraction: Story = {
88
+ args: { open: false, onClose: () => {}, children: null },
89
+ render: (_args) => <DialogDemo size="md" label="Interaction test dialog" />,
90
+ play: async ({ canvasElement }) => {
91
+ const canvas = within(canvasElement);
92
+ const trigger = canvas.getByRole('button', { name: /open md dialog/i });
93
+ await userEvent.click(trigger);
94
+ const dialog = canvas.getByRole('dialog');
95
+ await expect(dialog).toBeVisible();
96
+ },
97
+ };