@sovereignfs/ui 0.40.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 (186) 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/BalanceChip.module.css +32 -0
  6. package/dist/Breadcrumb.module.css +49 -0
  7. package/dist/Button.module.css +12 -3
  8. package/dist/ButtonGroup.module.css +29 -0
  9. package/dist/Card.module.css +6 -0
  10. package/dist/Checkbox.module.css +3 -1
  11. package/dist/Collapsible.module.css +70 -0
  12. package/dist/Combobox.module.css +122 -0
  13. package/dist/Command.module.css +80 -0
  14. package/dist/ConfirmDialog.module.css +5 -1
  15. package/dist/DataTable.module.css +28 -0
  16. package/dist/DatePicker.module.css +6 -2
  17. package/dist/Dialog.module.css +3 -0
  18. package/dist/HoverCard.module.css +3 -0
  19. package/dist/Input.module.css +6 -1
  20. package/dist/Item.module.css +70 -0
  21. package/dist/Kbd.module.css +14 -0
  22. package/dist/Label.module.css +11 -0
  23. package/dist/Marker.module.css +33 -0
  24. package/dist/MemberMultiSelect.module.css +27 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/NavigationMenu.module.css +53 -0
  29. package/dist/Pagination.module.css +98 -0
  30. package/dist/Progress.module.css +20 -0
  31. package/dist/RadioGroup.module.css +88 -0
  32. package/dist/Resizable.module.css +70 -0
  33. package/dist/ScrollArea.module.css +20 -0
  34. package/dist/Select.module.css +7 -3
  35. package/dist/Slider.module.css +95 -0
  36. package/dist/Table.module.css +45 -0
  37. package/dist/Tabs.module.css +6 -1
  38. package/dist/Toggle.module.css +6 -1
  39. package/dist/Typography.module.css +47 -0
  40. package/dist/index.d.ts +698 -5
  41. package/dist/index.js +1938 -482
  42. package/dist/tokens/primitives.css +22 -8
  43. package/package.json +1 -1
  44. package/src/components/Accordion/Accordion.module.css +3 -0
  45. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  46. package/src/components/Accordion/Accordion.tsx +53 -0
  47. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  48. package/src/components/Alert/Alert.module.css +62 -0
  49. package/src/components/Alert/Alert.stories.tsx +96 -0
  50. package/src/components/Alert/Alert.tsx +58 -0
  51. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  52. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  53. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  54. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  55. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  56. package/src/components/Badge/Badge.module.css +75 -9
  57. package/src/components/Badge/Badge.stories.tsx +109 -1
  58. package/src/components/Badge/Badge.tsx +30 -2
  59. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  60. package/src/components/BalanceChip/BalanceChip.module.css +32 -0
  61. package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
  62. package/src/components/BalanceChip/BalanceChip.tsx +33 -0
  63. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  64. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  65. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  66. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  67. package/src/components/Button/Button.module.css +12 -3
  68. package/src/components/Button/Button.stories.tsx +3 -2
  69. package/src/components/Button/Button.tsx +1 -1
  70. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  71. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  72. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  73. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  74. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  75. package/src/components/Card/Card.module.css +6 -0
  76. package/src/components/Checkbox/Checkbox.module.css +3 -1
  77. package/src/components/Collapsible/Collapsible.module.css +70 -0
  78. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  79. package/src/components/Collapsible/Collapsible.tsx +66 -0
  80. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  81. package/src/components/Combobox/Combobox.module.css +122 -0
  82. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  83. package/src/components/Combobox/Combobox.tsx +186 -0
  84. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  85. package/src/components/Command/Command.module.css +80 -0
  86. package/src/components/Command/Command.stories.tsx +104 -0
  87. package/src/components/Command/Command.tsx +168 -0
  88. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  89. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  90. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  91. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  92. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  93. package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
  94. package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
  95. package/src/components/DataTable/DataTable.module.css +28 -0
  96. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  97. package/src/components/DataTable/DataTable.tsx +154 -0
  98. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  99. package/src/components/DatePicker/DatePicker.module.css +6 -2
  100. package/src/components/Dialog/Dialog.module.css +3 -0
  101. package/src/components/HoverCard/HoverCard.module.css +3 -0
  102. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  103. package/src/components/HoverCard/HoverCard.tsx +111 -0
  104. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  105. package/src/components/Icon/icons/circle-check.tsx +21 -0
  106. package/src/components/Icon/icons/circle-x.tsx +22 -0
  107. package/src/components/Icon/icons/index.ts +8 -0
  108. package/src/components/Input/Input.module.css +6 -1
  109. package/src/components/Item/Item.module.css +70 -0
  110. package/src/components/Item/Item.stories.tsx +62 -0
  111. package/src/components/Item/Item.tsx +54 -0
  112. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  113. package/src/components/Kbd/Kbd.module.css +14 -0
  114. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  115. package/src/components/Kbd/Kbd.tsx +12 -0
  116. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  117. package/src/components/Label/Label.module.css +11 -0
  118. package/src/components/Label/Label.stories.tsx +40 -0
  119. package/src/components/Label/Label.tsx +24 -0
  120. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  121. package/src/components/Marker/Marker.module.css +33 -0
  122. package/src/components/Marker/Marker.stories.tsx +39 -0
  123. package/src/components/Marker/Marker.tsx +34 -0
  124. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  125. package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
  126. package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
  127. package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
  128. package/src/components/Menu/Menu.module.css +8 -1
  129. package/src/components/Menu/Menu.tsx +30 -8
  130. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  131. package/src/components/Menubar/Menubar.tsx +42 -0
  132. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  133. package/src/components/Message/Message.module.css +99 -0
  134. package/src/components/Message/Message.stories.tsx +79 -0
  135. package/src/components/Message/Message.tsx +41 -0
  136. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  137. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  138. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  139. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  140. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  141. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  142. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  143. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  144. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  145. package/src/components/Pagination/Pagination.module.css +98 -0
  146. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  147. package/src/components/Pagination/Pagination.tsx +87 -0
  148. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  149. package/src/components/Progress/Progress.module.css +20 -0
  150. package/src/components/Progress/Progress.stories.tsx +40 -0
  151. package/src/components/Progress/Progress.tsx +35 -0
  152. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  153. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  154. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  155. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  156. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  157. package/src/components/Resizable/Resizable.module.css +70 -0
  158. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  159. package/src/components/Resizable/Resizable.tsx +313 -0
  160. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  161. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  162. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  163. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  164. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  165. package/src/components/Select/Select.module.css +7 -3
  166. package/src/components/Slider/Slider.module.css +95 -0
  167. package/src/components/Slider/Slider.stories.tsx +64 -0
  168. package/src/components/Slider/Slider.tsx +77 -0
  169. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  170. package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
  171. package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
  172. package/src/components/Table/Table.module.css +45 -0
  173. package/src/components/Table/Table.stories.tsx +125 -0
  174. package/src/components/Table/Table.tsx +51 -0
  175. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  176. package/src/components/Tabs/Tabs.module.css +6 -1
  177. package/src/components/Toggle/Toggle.module.css +6 -1
  178. package/src/components/Typography/Typography.module.css +47 -0
  179. package/src/components/Typography/Typography.stories.tsx +62 -0
  180. package/src/components/Typography/Typography.tsx +30 -0
  181. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  182. package/src/index.ts +92 -1
  183. package/src/stories/DesignSystemOverview.stories.tsx +706 -2
  184. package/src/stories/MobilePatterns.stories.tsx +8 -2
  185. package/src/stories/TokenGallery.stories.tsx +112 -16
  186. package/src/tokens/primitives.css +22 -8
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { cloneElement, useEffect, useRef, useState } from 'react';
4
+ import type { ReactElement, ReactNode } from 'react';
5
+ import { Popover } from '../Popover/Popover';
6
+ import styles from './HoverCard.module.css';
7
+
8
+ export interface HoverCardProps {
9
+ /** The element that opens the card — cloned with hover/focus handlers
10
+ * (desktop) or a tap handler (touch). */
11
+ trigger: ReactElement;
12
+ children: ReactNode;
13
+ align?: 'left' | 'right';
14
+ width?: number | 'trigger';
15
+ 'aria-label': string;
16
+ }
17
+
18
+ const OPEN_DELAY_MS = 400;
19
+ const CLOSE_DELAY_MS = 150;
20
+
21
+ /** Whether the primary pointer can hover. Distinct from viewport-width-based
22
+ * mobile detection: a touchscreen laptop with a mouse as its primary
23
+ * pointer should still get real hover behavior, and a large-viewport tablet
24
+ * with only touch should not — viewport width doesn't capture that, `(hover:
25
+ * hover)` does, matching the pointer-based gating this design system already
26
+ * uses in CSS (e.g. Button/Checkbox's touch-target expansion). Defaults to
27
+ * `true`: this only changes which event handlers get attached, not the
28
+ * initial DOM shape, so there's no hydration-mismatch risk to guard against
29
+ * the way `useIsMobile` does. */
30
+ function useHoverCapable(): boolean {
31
+ const [hoverCapable, setHoverCapable] = useState(true);
32
+
33
+ useEffect(() => {
34
+ const mql = window.matchMedia('(hover: hover)');
35
+ setHoverCapable(mql.matches);
36
+ function handleChange(e: MediaQueryListEvent) {
37
+ setHoverCapable(e.matches);
38
+ }
39
+ mql.addEventListener('change', handleChange);
40
+ return () => mql.removeEventListener('change', handleChange);
41
+ }, []);
42
+
43
+ return hoverCapable;
44
+ }
45
+
46
+ /**
47
+ * HoverCard — hover-triggered popover on desktop, tap-to-toggle on touch.
48
+ *
49
+ * Built on `Popover` rather than reimplementing positioning/collision
50
+ * detection: this only adds the hover-intent open/close timing (avoids
51
+ * flicker on an accidental mouse pass-over, and gives the pointer time to
52
+ * travel from the trigger to the card itself across the gap between them)
53
+ * and the touch fallback. Also opens on keyboard focus and closes on blur —
54
+ * a hover-only trigger is a WCAG failure, since keyboard users can't hover.
55
+ */
56
+ export function HoverCard({
57
+ trigger,
58
+ children,
59
+ align,
60
+ width,
61
+ 'aria-label': ariaLabel,
62
+ }: HoverCardProps) {
63
+ const [open, setOpen] = useState(false);
64
+ const hoverCapable = useHoverCapable();
65
+ const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
66
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
67
+
68
+ function scheduleOpen() {
69
+ clearTimeout(closeTimer.current);
70
+ openTimer.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);
71
+ }
72
+
73
+ function scheduleClose() {
74
+ clearTimeout(openTimer.current);
75
+ closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
76
+ }
77
+
78
+ function cancelScheduledClose() {
79
+ clearTimeout(closeTimer.current);
80
+ }
81
+
82
+ const triggerHandlers = hoverCapable
83
+ ? {
84
+ onMouseEnter: scheduleOpen,
85
+ onMouseLeave: scheduleClose,
86
+ onFocus: () => setOpen(true),
87
+ onBlur: scheduleClose,
88
+ }
89
+ : { onClick: () => setOpen((o) => !o) };
90
+
91
+ const wrappedTrigger = cloneElement(trigger, triggerHandlers);
92
+
93
+ return (
94
+ <div
95
+ className={styles.root}
96
+ onMouseEnter={hoverCapable ? cancelScheduledClose : undefined}
97
+ onMouseLeave={hoverCapable ? scheduleClose : undefined}
98
+ >
99
+ <Popover
100
+ trigger={wrappedTrigger}
101
+ open={open}
102
+ onClose={() => setOpen(false)}
103
+ align={align}
104
+ width={width}
105
+ aria-label={ariaLabel}
106
+ >
107
+ {children}
108
+ </Popover>
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { HoverCard } from '../HoverCard';
5
+
6
+ let matches = true;
7
+
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches,
13
+ media: query,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ })),
20
+ );
21
+ }
22
+
23
+ describe('HoverCard', () => {
24
+ beforeEach(() => {
25
+ matches = true; // hover-capable by default
26
+ installMatchMedia();
27
+ vi.useFakeTimers();
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.runOnlyPendingTimers();
32
+ vi.useRealTimers();
33
+ vi.unstubAllGlobals();
34
+ cleanup();
35
+ });
36
+
37
+ it('does not open immediately on mouse enter (hover-intent delay)', () => {
38
+ render(
39
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
40
+ Card content
41
+ </HoverCard>,
42
+ );
43
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
44
+ expect(screen.queryByText('Card content')).toBeNull();
45
+ });
46
+
47
+ it('opens after the hover-intent delay elapses', () => {
48
+ render(
49
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
50
+ Card content
51
+ </HoverCard>,
52
+ );
53
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
54
+ act(() => {
55
+ vi.advanceTimersByTime(500);
56
+ });
57
+ expect(screen.getByText('Card content')).toBeDefined();
58
+ });
59
+
60
+ it('cancels the open timer if the pointer leaves before the delay elapses', () => {
61
+ render(
62
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
63
+ Card content
64
+ </HoverCard>,
65
+ );
66
+ const trigger = screen.getByRole('button', { name: 'Profile' });
67
+ fireEvent.mouseEnter(trigger);
68
+ fireEvent.mouseLeave(trigger);
69
+ act(() => {
70
+ vi.advanceTimersByTime(1000);
71
+ });
72
+ expect(screen.queryByText('Card content')).toBeNull();
73
+ });
74
+
75
+ it('opens immediately on keyboard focus, for keyboard accessibility', () => {
76
+ render(
77
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
78
+ Card content
79
+ </HoverCard>,
80
+ );
81
+ fireEvent.focus(screen.getByRole('button', { name: 'Profile' }));
82
+ expect(screen.getByText('Card content')).toBeDefined();
83
+ });
84
+
85
+ it('falls back to tap-to-toggle when the pointer cannot hover', () => {
86
+ matches = false;
87
+ installMatchMedia();
88
+ render(
89
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
90
+ Card content
91
+ </HoverCard>,
92
+ );
93
+ const trigger = screen.getByRole('button', { name: 'Profile' });
94
+ fireEvent.click(trigger);
95
+ expect(screen.getByText('Card content')).toBeDefined();
96
+ fireEvent.click(trigger);
97
+ expect(screen.queryByText('Card content')).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,21 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CircleCheckIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <path d="m9 12 2 2 4-4" />
19
+ </svg>
20
+ );
21
+ }
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CircleXIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <path d="m15 9-6 6" />
19
+ <path d="m9 9 6 6" />
20
+ </svg>
21
+ );
22
+ }
@@ -26,6 +26,8 @@ import { PackageIcon } from './package';
26
26
  import { Grid2x2Icon } from './grid-2x2';
27
27
  import { InfoIcon } from './info';
28
28
  import { AlertTriangleIcon } from './alert-triangle';
29
+ import { CircleCheckIcon } from './circle-check';
30
+ import { CircleXIcon } from './circle-x';
29
31
  import { CalendarIcon } from './calendar';
30
32
  import { SlidersHorizontalIcon } from './sliders-horizontal';
31
33
  import { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -80,6 +82,8 @@ export type IconName =
80
82
  | 'grid-2x2'
81
83
  | 'info'
82
84
  | 'alert-triangle'
85
+ | 'circle-check'
86
+ | 'circle-x'
83
87
  | 'calendar'
84
88
  | 'sliders-horizontal'
85
89
  | 'ellipsis-vertical'
@@ -136,6 +140,8 @@ export const ICONS: Record<IconName, IconComponent> = {
136
140
  'grid-2x2': Grid2x2Icon,
137
141
  info: InfoIcon,
138
142
  'alert-triangle': AlertTriangleIcon,
143
+ 'circle-check': CircleCheckIcon,
144
+ 'circle-x': CircleXIcon,
139
145
  calendar: CalendarIcon,
140
146
  'sliders-horizontal': SlidersHorizontalIcon,
141
147
  'ellipsis-vertical': EllipsisVerticalIcon,
@@ -190,6 +196,8 @@ export { PackageIcon } from './package';
190
196
  export { Grid2x2Icon } from './grid-2x2';
191
197
  export { InfoIcon } from './info';
192
198
  export { AlertTriangleIcon } from './alert-triangle';
199
+ export { CircleCheckIcon } from './circle-check';
200
+ export { CircleXIcon } from './circle-x';
193
201
  export { CalendarIcon } from './calendar';
194
202
  export { SlidersHorizontalIcon } from './sliders-horizontal';
195
203
  export { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -1,7 +1,12 @@
1
1
  .input {
2
2
  display: block;
3
3
  width: 100%;
4
- padding: var(--sv-space-2) var(--sv-space-3);
4
+ /* 36px rendered height. Explicit height + border-box (rather than
5
+ deriving height from padding and line-height) gives an exact,
6
+ browser-independent target instead of an approximation. */
7
+ height: 36px;
8
+ box-sizing: border-box;
9
+ padding: 4px var(--sv-space-3);
5
10
  font-family: var(--sv-font-family);
6
11
  font-size: var(--sv-font-size-sm);
7
12
  line-height: 1.5;
@@ -0,0 +1,70 @@
1
+ .root {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-3);
5
+ width: 100%;
6
+ padding: var(--sv-space-3);
7
+ border: none;
8
+ border-radius: var(--sv-radius-md);
9
+ background: transparent;
10
+ text-align: left;
11
+ font-family: var(--sv-font-family);
12
+ color: inherit;
13
+ }
14
+
15
+ .interactive {
16
+ cursor: pointer;
17
+ }
18
+
19
+ @media (hover: hover) {
20
+ .interactive:not(:disabled):hover {
21
+ background: var(--sv-color-surface-sunken);
22
+ }
23
+ }
24
+
25
+ .interactive:focus-visible {
26
+ outline: 2px solid var(--sv-color-focus-ring);
27
+ outline-offset: -2px;
28
+ }
29
+
30
+ .interactive:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ @media (pointer: coarse) {
36
+ .root {
37
+ min-height: var(--sv-touch-target-min, 44px);
38
+ }
39
+ }
40
+
41
+ .leading,
42
+ .trailing {
43
+ flex-shrink: 0;
44
+ display: flex;
45
+ align-items: center;
46
+ }
47
+
48
+ .content {
49
+ display: flex;
50
+ flex-direction: column;
51
+ min-width: 0;
52
+ flex: 1;
53
+ }
54
+
55
+ .title {
56
+ font-size: var(--sv-font-size-sm);
57
+ font-weight: var(--sv-font-weight-medium);
58
+ color: var(--sv-color-text-primary);
59
+ overflow: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ }
63
+
64
+ .description {
65
+ font-size: var(--sv-font-size-caption);
66
+ color: var(--sv-color-text-muted);
67
+ overflow: hidden;
68
+ text-overflow: ellipsis;
69
+ white-space: nowrap;
70
+ }
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Item } from './Item';
3
+ import { Icon } from '../Icon/Icon';
4
+
5
+ const meta = {
6
+ title: 'Components/Item',
7
+ component: Item,
8
+ parameters: { layout: 'padded' },
9
+ args: {
10
+ title: 'Notifications',
11
+ },
12
+ } satisfies Meta<typeof Item>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const Default: Story = {};
20
+
21
+ export const WithDescription: Story = {
22
+ args: { description: 'Manage email and push alerts' },
23
+ };
24
+
25
+ export const Interactive: Story = {
26
+ args: {
27
+ description: 'Manage email and push alerts',
28
+ leading: <Icon name="bell" aria-hidden />,
29
+ trailing: <Icon name="chevron-right" size="sm" aria-hidden />,
30
+ onClick: () => {},
31
+ },
32
+ };
33
+
34
+ export const Disabled: Story = {
35
+ args: {
36
+ description: 'Requires admin role',
37
+ onClick: () => {},
38
+ disabled: true,
39
+ },
40
+ };
41
+
42
+ export const List: Story = {
43
+ render: () => (
44
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
45
+ <Item
46
+ title="Notifications"
47
+ description="Manage email and push alerts"
48
+ leading={<Icon name="bell" aria-hidden />}
49
+ trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
50
+ onClick={() => {}}
51
+ />
52
+ <Item
53
+ title="Privacy"
54
+ description="Control what others can see"
55
+ leading={<Icon name="shield" aria-hidden />}
56
+ trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
57
+ onClick={() => {}}
58
+ />
59
+ <Item title="Sign out" leading={<Icon name="log-out" aria-hidden />} onClick={() => {}} />
60
+ </div>
61
+ ),
62
+ };
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './Item.module.css';
3
+
4
+ export interface ItemProps {
5
+ leading?: ReactNode;
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ trailing?: ReactNode;
9
+ /** Renders as a button when given; a plain row otherwise. */
10
+ onClick?: () => void;
11
+ disabled?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Item — generic row primitive: leading slot, title + optional description,
17
+ * trailing slot. The building block for settings rows, list rows, and
18
+ * menu-adjacent rows that don't fit `Menu`'s own item shape.
19
+ */
20
+ export function Item({
21
+ leading,
22
+ title,
23
+ description,
24
+ trailing,
25
+ onClick,
26
+ disabled,
27
+ className,
28
+ }: ItemProps) {
29
+ const interactive = Boolean(onClick);
30
+ const classes = [styles.root, interactive ? styles.interactive : '', className]
31
+ .filter(Boolean)
32
+ .join(' ');
33
+
34
+ const content = (
35
+ <>
36
+ {leading && <span className={styles.leading}>{leading}</span>}
37
+ <span className={styles.content}>
38
+ <span className={styles.title}>{title}</span>
39
+ {description && <span className={styles.description}>{description}</span>}
40
+ </span>
41
+ {trailing && <span className={styles.trailing}>{trailing}</span>}
42
+ </>
43
+ );
44
+
45
+ if (interactive) {
46
+ return (
47
+ <button type="button" onClick={onClick} disabled={disabled} className={classes}>
48
+ {content}
49
+ </button>
50
+ );
51
+ }
52
+
53
+ return <div className={classes}>{content}</div>;
54
+ }
@@ -0,0 +1,39 @@
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 { Item } from '../Item';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Item', () => {
9
+ it('renders as a plain row when no onClick is given', () => {
10
+ render(<Item title="Notifications" description="Manage email and push alerts" />);
11
+ expect(screen.queryByRole('button')).toBeNull();
12
+ expect(screen.getByText('Notifications')).toBeDefined();
13
+ expect(screen.getByText('Manage email and push alerts')).toBeDefined();
14
+ });
15
+
16
+ it('renders as a button and fires onClick when given', () => {
17
+ const onClick = vi.fn();
18
+ render(<Item title="Notifications" onClick={onClick} />);
19
+ fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
20
+ expect(onClick).toHaveBeenCalled();
21
+ });
22
+
23
+ it('renders leading and trailing slots', () => {
24
+ render(
25
+ <Item
26
+ title="Notifications"
27
+ leading={<span data-testid="leading">icon</span>}
28
+ trailing={<span data-testid="trailing">chevron</span>}
29
+ />,
30
+ );
31
+ expect(screen.getByTestId('leading')).toBeDefined();
32
+ expect(screen.getByTestId('trailing')).toBeDefined();
33
+ });
34
+
35
+ it('disables the button when disabled is set', () => {
36
+ render(<Item title="Notifications" onClick={() => {}} disabled />);
37
+ expect(screen.getByRole('button')).toHaveProperty('disabled', true);
38
+ });
39
+ });
@@ -0,0 +1,14 @@
1
+ .kbd {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ min-width: 1.5em;
6
+ padding: var(--sv-space-1) var(--sv-space-2);
7
+ border: 1px solid var(--sv-color-border-strong);
8
+ border-radius: var(--sv-radius-sm);
9
+ background: var(--sv-color-surface-sunken);
10
+ color: var(--sv-color-text-primary);
11
+ font-family: var(--sv-font-family-mono);
12
+ font-size: var(--sv-font-size-xs);
13
+ line-height: 1;
14
+ }
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Kbd } from './Kbd';
3
+
4
+ const meta = {
5
+ title: 'Components/Kbd',
6
+ component: Kbd,
7
+ parameters: { layout: 'padded' },
8
+ args: { children: '⌘K' },
9
+ } satisfies Meta<typeof Kbd>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const Default: Story = {};
17
+
18
+ export const Letter: Story = {
19
+ args: { children: 'S' },
20
+ };
21
+
22
+ export const Sequence: Story = {
23
+ render: () => (
24
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
25
+ <Kbd>Ctrl</Kbd>
26
+ <span>+</span>
27
+ <Kbd>Shift</Kbd>
28
+ <span>+</span>
29
+ <Kbd>P</Kbd>
30
+ </span>
31
+ ),
32
+ };
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './Kbd.module.css';
3
+
4
+ export interface KbdProps {
5
+ children: ReactNode;
6
+ className?: string;
7
+ }
8
+
9
+ /** Kbd — inline keyboard-key styling. Renders a real `<kbd>` element. */
10
+ export function Kbd({ children, className }: KbdProps) {
11
+ return <kbd className={[styles.kbd, className].filter(Boolean).join(' ')}>{children}</kbd>;
12
+ }
@@ -0,0 +1,14 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Kbd } from '../Kbd';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Kbd', () => {
9
+ it('renders a real <kbd> element', () => {
10
+ render(<Kbd>⌘K</Kbd>);
11
+ const el = screen.getByText('⌘K');
12
+ expect(el.tagName).toBe('KBD');
13
+ });
14
+ });
@@ -0,0 +1,11 @@
1
+ .label {
2
+ font-size: var(--sv-font-size-sm);
3
+ font-weight: var(--sv-font-weight-medium);
4
+ color: var(--sv-color-text-primary);
5
+ cursor: default;
6
+ }
7
+
8
+ .disabled {
9
+ opacity: 0.5;
10
+ cursor: not-allowed;
11
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Label } from './Label';
3
+
4
+ const meta = {
5
+ title: 'Components/Label',
6
+ component: Label,
7
+ parameters: { layout: 'padded' },
8
+ args: { children: 'Label text' },
9
+ } satisfies Meta<typeof Label>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const Default: Story = {};
17
+
18
+ export const Disabled: Story = {
19
+ args: { disabled: true },
20
+ };
21
+
22
+ export const WithControl: Story = {
23
+ render: () => (
24
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
25
+ <Label htmlFor="example-input">Email address</Label>
26
+ <input
27
+ id="example-input"
28
+ type="email"
29
+ placeholder="you@example.com"
30
+ style={{
31
+ padding: 'var(--sv-space-2) var(--sv-space-3)',
32
+ border: '1px solid var(--sv-color-border-strong)',
33
+ borderRadius: 'var(--sv-radius-md)',
34
+ fontFamily: 'var(--sv-font-family)',
35
+ fontSize: 'var(--sv-font-size-sm)',
36
+ }}
37
+ />
38
+ </div>
39
+ ),
40
+ };