@sovereignfs/ui 0.41.0 → 0.54.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 (256) 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/LegalLinks.module.css +24 -0
  23. package/dist/Markdown.module.css +49 -0
  24. package/dist/Marker.module.css +33 -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/MobileAppsDrawer.module.css +81 -0
  29. package/dist/MobileFooter.module.css +73 -0
  30. package/dist/MobileHeader.module.css +63 -0
  31. package/dist/NavigationMenu.module.css +53 -0
  32. package/dist/PageContainer.module.css +20 -0
  33. package/dist/Pagination.module.css +98 -0
  34. package/dist/Progress.module.css +20 -0
  35. package/dist/RadioGroup.module.css +88 -0
  36. package/dist/Resizable.module.css +70 -0
  37. package/dist/ScrollArea.module.css +20 -0
  38. package/dist/Select.module.css +7 -3
  39. package/dist/Slider.module.css +95 -0
  40. package/dist/SwipableMobileCarousel.module.css +55 -0
  41. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  42. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  43. package/dist/Table.module.css +45 -0
  44. package/dist/Tabs.module.css +6 -1
  45. package/dist/Toggle.module.css +6 -1
  46. package/dist/Typography.module.css +47 -0
  47. package/dist/index.d.ts +1268 -8
  48. package/dist/index.js +3046 -975
  49. package/dist/tokens/primitives.css +47 -8
  50. package/package.json +13 -9
  51. package/src/components/Accordion/Accordion.module.css +3 -0
  52. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  53. package/src/components/Accordion/Accordion.tsx +53 -0
  54. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  55. package/src/components/Alert/Alert.module.css +62 -0
  56. package/src/components/Alert/Alert.stories.tsx +96 -0
  57. package/src/components/Alert/Alert.tsx +58 -0
  58. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  59. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  60. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  61. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  62. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  63. package/src/components/Badge/Badge.module.css +75 -9
  64. package/src/components/Badge/Badge.stories.tsx +109 -1
  65. package/src/components/Badge/Badge.tsx +31 -9
  66. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  67. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  68. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  69. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  70. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  71. package/src/components/Button/Button.module.css +12 -3
  72. package/src/components/Button/Button.stories.tsx +3 -2
  73. package/src/components/Button/Button.tsx +1 -1
  74. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  75. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  76. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  77. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  78. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  79. package/src/components/Card/Card.module.css +6 -0
  80. package/src/components/Checkbox/Checkbox.module.css +3 -1
  81. package/src/components/Collapsible/Collapsible.module.css +70 -0
  82. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  83. package/src/components/Collapsible/Collapsible.tsx +66 -0
  84. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  85. package/src/components/Combobox/Combobox.module.css +122 -0
  86. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  87. package/src/components/Combobox/Combobox.tsx +186 -0
  88. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  89. package/src/components/Command/Command.module.css +80 -0
  90. package/src/components/Command/Command.stories.tsx +104 -0
  91. package/src/components/Command/Command.tsx +168 -0
  92. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  93. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  94. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  95. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  96. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  97. package/src/components/DataTable/DataTable.module.css +28 -0
  98. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  99. package/src/components/DataTable/DataTable.tsx +154 -0
  100. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  101. package/src/components/DatePicker/DatePicker.module.css +6 -2
  102. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  103. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  104. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  105. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  106. package/src/components/Dialog/Dialog.module.css +3 -0
  107. package/src/components/Dialog/Dialog.tsx +8 -59
  108. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  109. package/src/components/Drawer/Drawer.tsx +18 -56
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  111. package/src/components/HoverCard/HoverCard.module.css +3 -0
  112. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  113. package/src/components/HoverCard/HoverCard.tsx +111 -0
  114. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  115. package/src/components/Icon/icons/circle-check.tsx +21 -0
  116. package/src/components/Icon/icons/circle-x.tsx +22 -0
  117. package/src/components/Icon/icons/index.ts +16 -0
  118. package/src/components/Icon/icons/menu.tsx +22 -0
  119. package/src/components/Icon/icons/smartphone.tsx +21 -0
  120. package/src/components/Input/Input.module.css +6 -1
  121. package/src/components/Item/Item.module.css +70 -0
  122. package/src/components/Item/Item.stories.tsx +62 -0
  123. package/src/components/Item/Item.tsx +54 -0
  124. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  125. package/src/components/Kbd/Kbd.module.css +14 -0
  126. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  127. package/src/components/Kbd/Kbd.tsx +12 -0
  128. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  129. package/src/components/Label/Label.module.css +11 -0
  130. package/src/components/Label/Label.stories.tsx +40 -0
  131. package/src/components/Label/Label.tsx +24 -0
  132. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  133. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  134. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  135. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  136. package/src/components/Markdown/Markdown.module.css +49 -0
  137. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  138. package/src/components/Markdown/Markdown.tsx +168 -0
  139. package/src/components/Marker/Marker.module.css +33 -0
  140. package/src/components/Marker/Marker.stories.tsx +39 -0
  141. package/src/components/Marker/Marker.tsx +34 -0
  142. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  143. package/src/components/Menu/Menu.module.css +8 -1
  144. package/src/components/Menu/Menu.tsx +30 -8
  145. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  146. package/src/components/Menubar/Menubar.tsx +42 -0
  147. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  148. package/src/components/Message/Message.module.css +99 -0
  149. package/src/components/Message/Message.stories.tsx +79 -0
  150. package/src/components/Message/Message.tsx +41 -0
  151. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  152. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  153. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  154. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  155. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  156. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  157. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  158. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  159. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  160. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  161. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  162. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  163. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  164. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  165. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  166. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  167. package/src/components/NavTabs/NavTabs.tsx +39 -11
  168. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  169. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  170. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  171. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  172. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  173. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  174. package/src/components/PageContainer/PageContainer.module.css +20 -0
  175. package/src/components/PageContainer/PageContainer.tsx +31 -0
  176. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  177. package/src/components/PageHeader/PageHeader.tsx +16 -2
  178. package/src/components/Pagination/Pagination.module.css +98 -0
  179. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  180. package/src/components/Pagination/Pagination.tsx +87 -0
  181. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  182. package/src/components/Progress/Progress.module.css +20 -0
  183. package/src/components/Progress/Progress.stories.tsx +40 -0
  184. package/src/components/Progress/Progress.tsx +35 -0
  185. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  186. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  187. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  188. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  189. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  190. package/src/components/Resizable/Resizable.module.css +70 -0
  191. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  192. package/src/components/Resizable/Resizable.tsx +313 -0
  193. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  194. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  195. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  196. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  197. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  198. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  199. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  200. package/src/components/Select/Select.module.css +7 -3
  201. package/src/components/Sheet/Sheet.tsx +9 -51
  202. package/src/components/Slider/Slider.module.css +95 -0
  203. package/src/components/Slider/Slider.stories.tsx +64 -0
  204. package/src/components/Slider/Slider.tsx +77 -0
  205. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  206. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  207. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  208. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  209. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  210. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  211. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  212. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  213. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  214. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  215. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  216. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  217. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  218. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  219. package/src/components/Table/Table.module.css +45 -0
  220. package/src/components/Table/Table.stories.tsx +125 -0
  221. package/src/components/Table/Table.tsx +51 -0
  222. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  223. package/src/components/Tabs/Tabs.module.css +6 -1
  224. package/src/components/Toggle/Toggle.module.css +6 -1
  225. package/src/components/Typography/Typography.module.css +47 -0
  226. package/src/components/Typography/Typography.stories.tsx +62 -0
  227. package/src/components/Typography/Typography.tsx +30 -0
  228. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  229. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  230. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  231. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  232. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  233. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  234. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  235. package/src/hooks/index.ts +18 -0
  236. package/src/hooks/useCarouselRouteSync.ts +92 -0
  237. package/src/hooks/useIsOffline.ts +41 -0
  238. package/src/hooks/useOfflineTileState.ts +45 -0
  239. package/src/hooks/useResponsiveLayout.ts +36 -0
  240. package/src/hooks/useSnapCarousel.ts +79 -0
  241. package/src/hooks/useSwipeReveal.ts +107 -0
  242. package/src/index.ts +141 -3
  243. package/src/overlay-shell.ts +94 -0
  244. package/src/stories/DesignSystemOverview.stories.tsx +936 -3
  245. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  246. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  247. package/src/stories/InteractionHooks.stories.tsx +483 -0
  248. package/src/stories/MobilePatterns.stories.tsx +255 -4
  249. package/src/stories/NavTabs.stories.tsx +18 -0
  250. package/src/stories/OfflineGate.stories.tsx +42 -0
  251. package/src/stories/PageContainer.stories.tsx +37 -0
  252. package/src/stories/PageHeader.stories.tsx +11 -0
  253. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  254. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  255. package/src/stories/TokenGallery.stories.tsx +177 -16
  256. package/src/tokens/primitives.css +47 -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
+ }
@@ -3,10 +3,12 @@ import type { ReactElement, SVGProps } from 'react';
3
3
  import { HouseIcon } from './house';
4
4
  import { SettingsIcon } from './settings';
5
5
  import { LogOutIcon } from './log-out';
6
+ import { SmartphoneIcon } from './smartphone';
6
7
  import { ChevronRightIcon } from './chevron-right';
7
8
  import { ChevronLeftIcon } from './chevron-left';
8
9
  import { ChevronDownIcon } from './chevron-down';
9
10
  import { ChevronUpIcon } from './chevron-up';
11
+ import { MenuIcon } from './menu';
10
12
  import { XIcon } from './x';
11
13
  import { CheckIcon } from './check';
12
14
  import { PlusIcon } from './plus';
@@ -26,6 +28,8 @@ import { PackageIcon } from './package';
26
28
  import { Grid2x2Icon } from './grid-2x2';
27
29
  import { InfoIcon } from './info';
28
30
  import { AlertTriangleIcon } from './alert-triangle';
31
+ import { CircleCheckIcon } from './circle-check';
32
+ import { CircleXIcon } from './circle-x';
29
33
  import { CalendarIcon } from './calendar';
30
34
  import { SlidersHorizontalIcon } from './sliders-horizontal';
31
35
  import { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -57,10 +61,12 @@ export type IconName =
57
61
  | 'house'
58
62
  | 'settings'
59
63
  | 'log-out'
64
+ | 'smartphone'
60
65
  | 'chevron-right'
61
66
  | 'chevron-left'
62
67
  | 'chevron-down'
63
68
  | 'chevron-up'
69
+ | 'menu'
64
70
  | 'x'
65
71
  | 'check'
66
72
  | 'plus'
@@ -80,6 +86,8 @@ export type IconName =
80
86
  | 'grid-2x2'
81
87
  | 'info'
82
88
  | 'alert-triangle'
89
+ | 'circle-check'
90
+ | 'circle-x'
83
91
  | 'calendar'
84
92
  | 'sliders-horizontal'
85
93
  | 'ellipsis-vertical'
@@ -113,10 +121,12 @@ export const ICONS: Record<IconName, IconComponent> = {
113
121
  house: HouseIcon,
114
122
  settings: SettingsIcon,
115
123
  'log-out': LogOutIcon,
124
+ smartphone: SmartphoneIcon,
116
125
  'chevron-right': ChevronRightIcon,
117
126
  'chevron-left': ChevronLeftIcon,
118
127
  'chevron-down': ChevronDownIcon,
119
128
  'chevron-up': ChevronUpIcon,
129
+ menu: MenuIcon,
120
130
  x: XIcon,
121
131
  check: CheckIcon,
122
132
  plus: PlusIcon,
@@ -136,6 +146,8 @@ export const ICONS: Record<IconName, IconComponent> = {
136
146
  'grid-2x2': Grid2x2Icon,
137
147
  info: InfoIcon,
138
148
  'alert-triangle': AlertTriangleIcon,
149
+ 'circle-check': CircleCheckIcon,
150
+ 'circle-x': CircleXIcon,
139
151
  calendar: CalendarIcon,
140
152
  'sliders-horizontal': SlidersHorizontalIcon,
141
153
  'ellipsis-vertical': EllipsisVerticalIcon,
@@ -167,10 +179,12 @@ export const ICONS: Record<IconName, IconComponent> = {
167
179
  export { HouseIcon } from './house';
168
180
  export { SettingsIcon } from './settings';
169
181
  export { LogOutIcon } from './log-out';
182
+ export { SmartphoneIcon } from './smartphone';
170
183
  export { ChevronRightIcon } from './chevron-right';
171
184
  export { ChevronLeftIcon } from './chevron-left';
172
185
  export { ChevronDownIcon } from './chevron-down';
173
186
  export { ChevronUpIcon } from './chevron-up';
187
+ export { MenuIcon } from './menu';
174
188
  export { XIcon } from './x';
175
189
  export { CheckIcon } from './check';
176
190
  export { PlusIcon } from './plus';
@@ -190,6 +204,8 @@ export { PackageIcon } from './package';
190
204
  export { Grid2x2Icon } from './grid-2x2';
191
205
  export { InfoIcon } from './info';
192
206
  export { AlertTriangleIcon } from './alert-triangle';
207
+ export { CircleCheckIcon } from './circle-check';
208
+ export { CircleXIcon } from './circle-x';
193
209
  export { CalendarIcon } from './calendar';
194
210
  export { SlidersHorizontalIcon } from './sliders-horizontal';
195
211
  export { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -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 MenuIcon(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
+ <path d="M4 5h16" />
18
+ <path d="M4 12h16" />
19
+ <path d="M4 19h16" />
20
+ </svg>
21
+ );
22
+ }
@@ -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 SmartphoneIcon(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
+ <rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
18
+ <path d="M12 18h.01" />
19
+ </svg>
20
+ );
21
+ }
@@ -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
+ };