@sovereignfs/ui 0.41.0 → 0.45.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 (174) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/Marker.module.css +33 -0
  23. package/dist/Menu.module.css +8 -1
  24. package/dist/Message.module.css +99 -0
  25. package/dist/MessageScroller.module.css +54 -0
  26. package/dist/NavigationMenu.module.css +53 -0
  27. package/dist/Pagination.module.css +98 -0
  28. package/dist/Progress.module.css +20 -0
  29. package/dist/RadioGroup.module.css +88 -0
  30. package/dist/Resizable.module.css +70 -0
  31. package/dist/ScrollArea.module.css +20 -0
  32. package/dist/Select.module.css +7 -3
  33. package/dist/Slider.module.css +95 -0
  34. package/dist/Table.module.css +45 -0
  35. package/dist/Tabs.module.css +6 -1
  36. package/dist/Toggle.module.css +6 -1
  37. package/dist/Typography.module.css +47 -0
  38. package/dist/index.d.ts +617 -5
  39. package/dist/index.js +1847 -499
  40. package/dist/tokens/primitives.css +22 -8
  41. package/package.json +1 -1
  42. package/src/components/Accordion/Accordion.module.css +3 -0
  43. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  44. package/src/components/Accordion/Accordion.tsx +53 -0
  45. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  46. package/src/components/Alert/Alert.module.css +62 -0
  47. package/src/components/Alert/Alert.stories.tsx +96 -0
  48. package/src/components/Alert/Alert.tsx +58 -0
  49. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  50. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  51. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  52. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  53. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  54. package/src/components/Badge/Badge.module.css +75 -9
  55. package/src/components/Badge/Badge.stories.tsx +109 -1
  56. package/src/components/Badge/Badge.tsx +30 -2
  57. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  58. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  59. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  60. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  61. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  62. package/src/components/Button/Button.module.css +12 -3
  63. package/src/components/Button/Button.stories.tsx +3 -2
  64. package/src/components/Button/Button.tsx +1 -1
  65. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  66. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  67. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  68. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  69. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  70. package/src/components/Card/Card.module.css +6 -0
  71. package/src/components/Checkbox/Checkbox.module.css +3 -1
  72. package/src/components/Collapsible/Collapsible.module.css +70 -0
  73. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  74. package/src/components/Collapsible/Collapsible.tsx +66 -0
  75. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  76. package/src/components/Combobox/Combobox.module.css +122 -0
  77. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  78. package/src/components/Combobox/Combobox.tsx +186 -0
  79. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  80. package/src/components/Command/Command.module.css +80 -0
  81. package/src/components/Command/Command.stories.tsx +104 -0
  82. package/src/components/Command/Command.tsx +168 -0
  83. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  84. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  85. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  86. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  87. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  88. package/src/components/DataTable/DataTable.module.css +28 -0
  89. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  90. package/src/components/DataTable/DataTable.tsx +154 -0
  91. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  92. package/src/components/DatePicker/DatePicker.module.css +6 -2
  93. package/src/components/Dialog/Dialog.module.css +3 -0
  94. package/src/components/HoverCard/HoverCard.module.css +3 -0
  95. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  96. package/src/components/HoverCard/HoverCard.tsx +111 -0
  97. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  98. package/src/components/Icon/icons/circle-check.tsx +21 -0
  99. package/src/components/Icon/icons/circle-x.tsx +22 -0
  100. package/src/components/Icon/icons/index.ts +8 -0
  101. package/src/components/Input/Input.module.css +6 -1
  102. package/src/components/Item/Item.module.css +70 -0
  103. package/src/components/Item/Item.stories.tsx +62 -0
  104. package/src/components/Item/Item.tsx +54 -0
  105. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  106. package/src/components/Kbd/Kbd.module.css +14 -0
  107. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  108. package/src/components/Kbd/Kbd.tsx +12 -0
  109. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  110. package/src/components/Label/Label.module.css +11 -0
  111. package/src/components/Label/Label.stories.tsx +40 -0
  112. package/src/components/Label/Label.tsx +24 -0
  113. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  114. package/src/components/Marker/Marker.module.css +33 -0
  115. package/src/components/Marker/Marker.stories.tsx +39 -0
  116. package/src/components/Marker/Marker.tsx +34 -0
  117. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  118. package/src/components/Menu/Menu.module.css +8 -1
  119. package/src/components/Menu/Menu.tsx +30 -8
  120. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  121. package/src/components/Menubar/Menubar.tsx +42 -0
  122. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  123. package/src/components/Message/Message.module.css +99 -0
  124. package/src/components/Message/Message.stories.tsx +79 -0
  125. package/src/components/Message/Message.tsx +41 -0
  126. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  127. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  128. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  129. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  130. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  131. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  132. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  133. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  134. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  135. package/src/components/Pagination/Pagination.module.css +98 -0
  136. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  137. package/src/components/Pagination/Pagination.tsx +87 -0
  138. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  139. package/src/components/Progress/Progress.module.css +20 -0
  140. package/src/components/Progress/Progress.stories.tsx +40 -0
  141. package/src/components/Progress/Progress.tsx +35 -0
  142. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  143. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  144. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  145. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  146. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  147. package/src/components/Resizable/Resizable.module.css +70 -0
  148. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  149. package/src/components/Resizable/Resizable.tsx +313 -0
  150. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  151. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  152. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  153. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  154. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  155. package/src/components/Select/Select.module.css +7 -3
  156. package/src/components/Slider/Slider.module.css +95 -0
  157. package/src/components/Slider/Slider.stories.tsx +64 -0
  158. package/src/components/Slider/Slider.tsx +77 -0
  159. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  160. package/src/components/Table/Table.module.css +45 -0
  161. package/src/components/Table/Table.stories.tsx +125 -0
  162. package/src/components/Table/Table.tsx +51 -0
  163. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  164. package/src/components/Tabs/Tabs.module.css +6 -1
  165. package/src/components/Toggle/Toggle.module.css +6 -1
  166. package/src/components/Typography/Typography.module.css +47 -0
  167. package/src/components/Typography/Typography.stories.tsx +62 -0
  168. package/src/components/Typography/Typography.tsx +30 -0
  169. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  170. package/src/index.ts +78 -1
  171. package/src/stories/DesignSystemOverview.stories.tsx +614 -2
  172. package/src/stories/MobilePatterns.stories.tsx +8 -2
  173. package/src/stories/TokenGallery.stories.tsx +112 -16
  174. package/src/tokens/primitives.css +22 -8
@@ -20,6 +20,16 @@ describe('Button', () => {
20
20
  expect(button.className).toContain('sm');
21
21
  });
22
22
 
23
+ it('applies the lg size class', () => {
24
+ render(<Button size="lg">Upgrade</Button>);
25
+ expect(screen.getByRole('button', { name: 'Upgrade' }).className).toContain('lg');
26
+ });
27
+
28
+ it('defaults to the md size class', () => {
29
+ render(<Button>Confirm</Button>);
30
+ expect(screen.getByRole('button', { name: 'Confirm' }).className).toContain('md');
31
+ });
32
+
23
33
  it('defaults to type="button"', () => {
24
34
  render(<Button>Save</Button>);
25
35
  expect(screen.getByRole('button', { name: 'Save' }).getAttribute('type')).toBe('button');
@@ -0,0 +1,29 @@
1
+ .root {
2
+ display: inline-flex;
3
+ }
4
+
5
+ .root > :global(button),
6
+ .root > :global(a) {
7
+ border-radius: 0;
8
+ margin-left: -1px;
9
+ }
10
+
11
+ .root > :global(button):first-child,
12
+ .root > :global(a):first-child {
13
+ margin-left: 0;
14
+ border-top-left-radius: var(--sv-radius-md);
15
+ border-bottom-left-radius: var(--sv-radius-md);
16
+ }
17
+
18
+ .root > :global(button):last-child,
19
+ .root > :global(a):last-child {
20
+ border-top-right-radius: var(--sv-radius-md);
21
+ border-bottom-right-radius: var(--sv-radius-md);
22
+ }
23
+
24
+ /* Keep the focus ring above sibling borders instead of being clipped by them. */
25
+ .root > :global(button):focus-visible,
26
+ .root > :global(a):focus-visible {
27
+ position: relative;
28
+ z-index: 1;
29
+ }
@@ -0,0 +1,57 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { ButtonGroup } from './ButtonGroup';
4
+ import { Button } from '../Button/Button';
5
+
6
+ const meta = {
7
+ title: 'Components/ButtonGroup',
8
+ component: ButtonGroup,
9
+ parameters: { layout: 'padded' },
10
+ args: { children: null },
11
+ } satisfies Meta<typeof ButtonGroup>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const Default: Story = {
19
+ render: () => (
20
+ <ButtonGroup aria-label="View">
21
+ <Button variant="secondary" size="sm">
22
+ Day
23
+ </Button>
24
+ <Button variant="secondary" size="sm">
25
+ Week
26
+ </Button>
27
+ <Button variant="secondary" size="sm">
28
+ Month
29
+ </Button>
30
+ </ButtonGroup>
31
+ ),
32
+ };
33
+
34
+ export const WithActiveSelection: Story = {
35
+ render: () => {
36
+ const [view, setView] = useState<'day' | 'week' | 'month'>('week');
37
+ const options: { value: typeof view; label: string }[] = [
38
+ { value: 'day', label: 'Day' },
39
+ { value: 'week', label: 'Week' },
40
+ { value: 'month', label: 'Month' },
41
+ ];
42
+ return (
43
+ <ButtonGroup aria-label="View">
44
+ {options.map((option) => (
45
+ <Button
46
+ key={option.value}
47
+ variant={option.value === view ? 'primary' : 'secondary'}
48
+ size="sm"
49
+ onClick={() => setView(option.value)}
50
+ >
51
+ {option.label}
52
+ </Button>
53
+ ))}
54
+ </ButtonGroup>
55
+ );
56
+ },
57
+ };
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './ButtonGroup.module.css';
3
+
4
+ export interface ButtonGroupProps {
5
+ /** Adjacent Button elements. */
6
+ children: ReactNode;
7
+ className?: string;
8
+ 'aria-label'?: string;
9
+ }
10
+
11
+ /**
12
+ * ButtonGroup — visually joins adjacent `Button`s into one control (shared
13
+ * border, connected corners). Targets its children's rendered `<button>`/
14
+ * `<a>` elements directly rather than requiring a special child component,
15
+ * so it works with plain `Button`.
16
+ */
17
+ export function ButtonGroup({ children, className, 'aria-label': ariaLabel }: ButtonGroupProps) {
18
+ return (
19
+ <div
20
+ role="group"
21
+ aria-label={ariaLabel}
22
+ className={[styles.root, className].filter(Boolean).join(' ')}
23
+ >
24
+ {children}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,29 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { ButtonGroup } from '../ButtonGroup';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ButtonGroup', () => {
9
+ it('exposes a group role with the given aria-label', () => {
10
+ render(
11
+ <ButtonGroup aria-label="View">
12
+ <button type="button">Day</button>
13
+ <button type="button">Week</button>
14
+ </ButtonGroup>,
15
+ );
16
+ expect(screen.getByRole('group', { name: 'View' })).toBeDefined();
17
+ });
18
+
19
+ it('renders every child button', () => {
20
+ render(
21
+ <ButtonGroup aria-label="View">
22
+ <button type="button">Day</button>
23
+ <button type="button">Week</button>
24
+ <button type="button">Month</button>
25
+ </ButtonGroup>,
26
+ );
27
+ expect(screen.getAllByRole('button').length).toBe(3);
28
+ });
29
+ });
@@ -1,3 +1,9 @@
1
+ /* Sizing audit: reviewed padding/radius against this scale's other
2
+ surfaces. Left unchanged rather than edited: Card is consumed across
3
+ most first-party plugin screens, and changing its padding/radius
4
+ site-wide has a blast radius no visual regression tooling exists yet to
5
+ verify (see epic task 9.14, not landed). Revisit once that tooling
6
+ exists. */
1
7
  .card {
2
8
  background: var(--sv-color-surface-raised);
3
9
  border: 1px solid var(--sv-color-border);
@@ -5,7 +5,9 @@
5
5
  cursor: pointer;
6
6
  }
7
7
 
8
- /* The visual box */
8
+ /* The visual box. Sizing audit: reviewed against the rest of this scale's
9
+ compact controls. Kept the 18px box unchanged — deliberately on the
10
+ larger side for touch-friendliness, not a gap to close. */
9
11
  .box {
10
12
  --checkbox-box-size: 18px;
11
13
  position: relative;
@@ -0,0 +1,70 @@
1
+ .root {
2
+ border-bottom: 1px solid var(--sv-color-border);
3
+ }
4
+
5
+ .trigger {
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: space-between;
9
+ gap: var(--sv-space-2);
10
+ width: 100%;
11
+ padding: var(--sv-space-3) 0;
12
+ border: none;
13
+ background: transparent;
14
+ color: var(--sv-color-text-primary);
15
+ font-family: var(--sv-font-family);
16
+ font-size: var(--sv-font-size-sm);
17
+ font-weight: var(--sv-font-weight-medium);
18
+ text-align: left;
19
+ cursor: pointer;
20
+ }
21
+
22
+ .trigger:focus-visible {
23
+ outline: 2px solid var(--sv-color-focus-ring);
24
+ outline-offset: 2px;
25
+ border-radius: var(--sv-radius-sm);
26
+ }
27
+
28
+ .triggerLabel {
29
+ min-width: 0;
30
+ }
31
+
32
+ .chevron {
33
+ flex-shrink: 0;
34
+ color: var(--sv-color-text-muted);
35
+ transition: transform 0.2s ease;
36
+ }
37
+
38
+ .chevronOpen {
39
+ transform: rotate(180deg);
40
+ }
41
+
42
+ /* Expand/collapse via grid-template-rows (0fr closed, 1fr open) instead of
43
+ animating height, which can't transition to/from auto in CSS. The inner
44
+ wrapper clips content during the transition. */
45
+ .contentWrapper {
46
+ display: grid;
47
+ grid-template-rows: 0fr;
48
+ transition: grid-template-rows 0.2s ease;
49
+ }
50
+
51
+ .contentWrapper[data-open='true'] {
52
+ grid-template-rows: 1fr;
53
+ }
54
+
55
+ .contentInner {
56
+ overflow: hidden;
57
+ font-size: var(--sv-font-size-sm);
58
+ color: var(--sv-color-text-primary);
59
+ }
60
+
61
+ .contentWrapper[data-open='true'] .contentInner {
62
+ padding-bottom: var(--sv-space-3);
63
+ }
64
+
65
+ @media (prefers-reduced-motion: reduce) {
66
+ .contentWrapper,
67
+ .chevron {
68
+ transition: none;
69
+ }
70
+ }
@@ -0,0 +1,62 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Collapsible } from './Collapsible';
4
+
5
+ const meta = {
6
+ title: 'Components/Collapsible',
7
+ component: Collapsible,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Single expand/collapse primitive. Independently useful (e.g. a "show more" toggle), and composed internally by Accordion.',
14
+ },
15
+ },
16
+ },
17
+ args: { open: false, onOpenChange: () => {}, trigger: 'Trigger', children: 'Content' },
18
+ } satisfies Meta<typeof Collapsible>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const Default: Story = {
26
+ render: () => {
27
+ const [open, setOpen] = useState(false);
28
+ return (
29
+ <Collapsible open={open} onOpenChange={setOpen} trigger="What plugins are installed?">
30
+ Sovereign ships with the platform plugins (Console, Launcher, Account) plus whatever you've
31
+ declared in your own sovereign.plugins.json — nothing else is bundled by default.
32
+ </Collapsible>
33
+ );
34
+ },
35
+ };
36
+
37
+ export const StartsOpen: Story = {
38
+ render: () => {
39
+ const [open, setOpen] = useState(true);
40
+ return (
41
+ <Collapsible open={open} onOpenChange={setOpen} trigger="Advanced settings">
42
+ These settings are rarely needed — change them only if you know what you're doing.
43
+ </Collapsible>
44
+ );
45
+ },
46
+ };
47
+
48
+ export const LongContent: Story = {
49
+ render: () => {
50
+ const [open, setOpen] = useState(true);
51
+ return (
52
+ <Collapsible open={open} onOpenChange={setOpen} trigger="Full changelog">
53
+ <ul style={{ margin: 0, paddingLeft: 20 }}>
54
+ <li>Added component sizing alignment across the design system</li>
55
+ <li>Added RadioGroup, Slider, Progress, Table, Alert primitives</li>
56
+ <li>Added Breadcrumb, Pagination, and Kbd primitives</li>
57
+ <li>Added Accordion and Collapsible primitives</li>
58
+ </ul>
59
+ </Collapsible>
60
+ );
61
+ },
62
+ };
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import { Icon } from '../Icon/Icon';
6
+ import styles from './Collapsible.module.css';
7
+
8
+ export interface CollapsibleProps {
9
+ open: boolean;
10
+ onOpenChange: (open: boolean) => void;
11
+ trigger: ReactNode;
12
+ children: ReactNode;
13
+ id?: string;
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * Collapsible — single expand/collapse primitive. Independently useful
19
+ * (e.g. a "show more" toggle), and composed internally by `Accordion`.
20
+ *
21
+ * The expand/collapse animation uses `grid-template-rows: 0fr` → `1fr`
22
+ * rather than animating `height`, which can't transition to/from `auto`
23
+ * in CSS without JS-measuring pixel heights. Collapsed content stays in
24
+ * the DOM (so no layout jump on re-open) but is marked `inert` — removed
25
+ * from the tab order and the accessibility tree — so it isn't reachable
26
+ * while hidden.
27
+ */
28
+ export function Collapsible({
29
+ open,
30
+ onOpenChange,
31
+ trigger,
32
+ children,
33
+ id,
34
+ className,
35
+ }: CollapsibleProps) {
36
+ const generatedId = useId();
37
+ const contentId = `${id ?? generatedId}-content`;
38
+
39
+ return (
40
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
41
+ <button
42
+ type="button"
43
+ className={styles.trigger}
44
+ aria-expanded={open}
45
+ aria-controls={contentId}
46
+ onClick={() => onOpenChange(!open)}
47
+ >
48
+ <span className={styles.triggerLabel}>{trigger}</span>
49
+ <Icon
50
+ name="chevron-down"
51
+ size="sm"
52
+ aria-hidden
53
+ className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
54
+ />
55
+ </button>
56
+ <div
57
+ id={contentId}
58
+ className={styles.contentWrapper}
59
+ data-open={open}
60
+ inert={!open || undefined}
61
+ >
62
+ <div className={styles.contentInner}>{children}</div>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,71 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { Collapsible } from '../Collapsible';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Collapsible', () => {
9
+ it('exposes aria-expanded matching the open prop', () => {
10
+ const { rerender } = render(
11
+ <Collapsible open={false} onOpenChange={() => {}} trigger="More info">
12
+ Body content
13
+ </Collapsible>,
14
+ );
15
+ expect(screen.getByRole('button', { name: 'More info' }).getAttribute('aria-expanded')).toBe(
16
+ 'false',
17
+ );
18
+
19
+ rerender(
20
+ <Collapsible open onOpenChange={() => {}} trigger="More info">
21
+ Body content
22
+ </Collapsible>,
23
+ );
24
+ expect(screen.getByRole('button', { name: 'More info' }).getAttribute('aria-expanded')).toBe(
25
+ 'true',
26
+ );
27
+ });
28
+
29
+ it('calls onOpenChange with the toggled value when the trigger is clicked', () => {
30
+ const onOpenChange = vi.fn();
31
+ render(
32
+ <Collapsible open={false} onOpenChange={onOpenChange} trigger="More info">
33
+ Body content
34
+ </Collapsible>,
35
+ );
36
+ fireEvent.click(screen.getByRole('button', { name: 'More info' }));
37
+ expect(onOpenChange).toHaveBeenCalledWith(true);
38
+ });
39
+
40
+ it('marks collapsed content inert', () => {
41
+ render(
42
+ <Collapsible open={false} onOpenChange={() => {}} trigger="More info">
43
+ Body content
44
+ </Collapsible>,
45
+ );
46
+ const content = screen.getByText('Body content').closest('[inert]');
47
+ expect(content).not.toBeNull();
48
+ });
49
+
50
+ it('does not mark expanded content inert', () => {
51
+ render(
52
+ <Collapsible open onOpenChange={() => {}} trigger="More info">
53
+ Body content
54
+ </Collapsible>,
55
+ );
56
+ const content = screen.getByText('Body content').closest('[inert]');
57
+ expect(content).toBeNull();
58
+ });
59
+
60
+ it('associates the trigger with the content via aria-controls', () => {
61
+ render(
62
+ <Collapsible open onOpenChange={() => {}} trigger="More info" id="details">
63
+ Body content
64
+ </Collapsible>,
65
+ );
66
+ const button = screen.getByRole('button', { name: 'More info' });
67
+ const controlsId = button.getAttribute('aria-controls');
68
+ expect(controlsId).toBeTruthy();
69
+ expect(document.getElementById(controlsId ?? '')).not.toBeNull();
70
+ });
71
+ });
@@ -0,0 +1,122 @@
1
+ /* Trigger styled to match Input/DatePicker's height/border/radius/focus ring
2
+ * for visual consistency as a form field. */
3
+
4
+ .trigger {
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: space-between;
8
+ gap: var(--sv-space-2);
9
+ width: 100%;
10
+ height: 36px;
11
+ box-sizing: border-box;
12
+ padding: 4px var(--sv-space-3);
13
+ font-family: var(--sv-font-family);
14
+ font-size: var(--sv-font-size-sm);
15
+ line-height: 1.5;
16
+ color: var(--sv-color-text-primary);
17
+ background-color: var(--sv-color-surface);
18
+ border: 1px solid var(--sv-color-border-strong);
19
+ border-radius: var(--sv-radius-md);
20
+ cursor: pointer;
21
+ transition:
22
+ border-color 0.15s ease,
23
+ box-shadow 0.15s ease;
24
+ }
25
+
26
+ @media (pointer: coarse) {
27
+ .trigger {
28
+ font-size: var(--sv-font-size-md);
29
+ }
30
+ }
31
+
32
+ .trigger:focus-visible {
33
+ outline: none;
34
+ border-color: var(--sv-color-accent);
35
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
36
+ }
37
+
38
+ .trigger:disabled {
39
+ opacity: 0.5;
40
+ cursor: not-allowed;
41
+ }
42
+
43
+ .triggerValue {
44
+ overflow: hidden;
45
+ color: var(--sv-color-text-primary);
46
+ text-overflow: ellipsis;
47
+ white-space: nowrap;
48
+ }
49
+
50
+ .triggerPlaceholder {
51
+ color: var(--sv-color-text-muted);
52
+ }
53
+
54
+ .body {
55
+ display: flex;
56
+ flex-direction: column;
57
+ max-height: min(60vh, 360px);
58
+ }
59
+
60
+ .search {
61
+ flex-shrink: 0;
62
+ width: 100%;
63
+ border: none;
64
+ border-bottom: 1px solid var(--sv-color-border);
65
+ background: transparent;
66
+ color: var(--sv-color-text-primary);
67
+ font: inherit;
68
+ font-size: var(--sv-font-size-md);
69
+ padding: var(--sv-space-3);
70
+ }
71
+
72
+ .search:focus {
73
+ outline: none;
74
+ }
75
+
76
+ .search::placeholder {
77
+ color: var(--sv-color-text-muted);
78
+ }
79
+
80
+ .list {
81
+ list-style: none;
82
+ margin: 0;
83
+ padding: var(--sv-space-1);
84
+ overflow-y: auto;
85
+ }
86
+
87
+ .option {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: var(--sv-space-2);
91
+ width: 100%;
92
+ min-height: var(--sv-touch-target-min, 44px);
93
+ padding: var(--sv-space-2) var(--sv-space-3);
94
+ border: none;
95
+ border-radius: var(--sv-radius-sm);
96
+ background: transparent;
97
+ color: var(--sv-color-text-primary);
98
+ font: inherit;
99
+ font-size: var(--sv-font-size-sm);
100
+ text-align: left;
101
+ cursor: pointer;
102
+ }
103
+
104
+ .optionHighlighted {
105
+ background: var(--sv-color-surface-sunken);
106
+ }
107
+
108
+ .check {
109
+ display: inline-flex;
110
+ flex-shrink: 0;
111
+ width: var(--sv-icon-size-sm);
112
+ height: var(--sv-icon-size-sm);
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+
117
+ .empty {
118
+ padding: var(--sv-space-4) var(--sv-space-3);
119
+ color: var(--sv-color-text-muted);
120
+ font-size: var(--sv-font-size-sm);
121
+ text-align: center;
122
+ }
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Combobox } from './Combobox';
4
+
5
+ const OPTIONS = [
6
+ { value: 'tasks', label: 'sovereign-tasks' },
7
+ { value: 'ledger', label: 'sovereign-ledger' },
8
+ { value: 'shopper', label: 'sovereign-shopper' },
9
+ { value: 'healthlog', label: 'sovereign-healthlog' },
10
+ { value: 'plainwrite', label: 'sovereign-plainwrite' },
11
+ ];
12
+
13
+ const meta = {
14
+ title: 'Components/Combobox',
15
+ component: Combobox,
16
+ parameters: {
17
+ layout: 'padded',
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "A searchable single-select: Popover on desktop, a bottom-sheet Drawer on mobile — the same adaptive pattern as Menu and DatePicker. For a short fixed list where search adds no value, use Select's native <select> instead.",
22
+ },
23
+ },
24
+ },
25
+ args: { options: OPTIONS, value: null, onChange: () => {}, 'aria-label': 'Plugin' },
26
+ } satisfies Meta<typeof Combobox>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function ComboboxDemo() {
34
+ const [value, setValue] = useState<string | null>(null);
35
+ return (
36
+ <div style={{ width: 280 }}>
37
+ <Combobox
38
+ options={OPTIONS}
39
+ value={value}
40
+ onChange={setValue}
41
+ placeholder="Select a plugin"
42
+ aria-label="Plugin"
43
+ />
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export const Default: Story = {
49
+ render: () => <ComboboxDemo />,
50
+ };
51
+
52
+ export const Preselected: Story = {
53
+ render: () => {
54
+ function Demo() {
55
+ const [value, setValue] = useState<string | null>('ledger');
56
+ return (
57
+ <div style={{ width: 280 }}>
58
+ <Combobox options={OPTIONS} value={value} onChange={setValue} aria-label="Plugin" />
59
+ </div>
60
+ );
61
+ }
62
+ return <Demo />;
63
+ },
64
+ };
65
+
66
+ export const Disabled: Story = {
67
+ render: () => (
68
+ <div style={{ width: 280 }}>
69
+ <Combobox
70
+ options={OPTIONS}
71
+ value={null}
72
+ onChange={() => {}}
73
+ placeholder="Select a plugin"
74
+ aria-label="Plugin"
75
+ disabled
76
+ />
77
+ </div>
78
+ ),
79
+ };