@sovereignfs/ui 0.54.0 → 0.83.1

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 (277) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -0,0 +1,132 @@
1
+ .row {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: var(--sv-space-2);
5
+ }
6
+
7
+ .swatch {
8
+ width: var(--sv-space-8);
9
+ height: var(--sv-space-8);
10
+ border-radius: var(--sv-radius-md);
11
+ border: 2px solid transparent;
12
+ padding: 0;
13
+ cursor: pointer;
14
+ }
15
+
16
+ .swatch:disabled {
17
+ opacity: 0.5;
18
+ cursor: not-allowed;
19
+ }
20
+
21
+ .swatchSelected {
22
+ border-color: var(--sv-color-accent);
23
+ outline: 2px solid var(--sv-color-focus-ring);
24
+ outline-offset: 1px;
25
+ }
26
+
27
+ /* Dashed/outlined rather than filled — there's no real color value to paint
28
+ for "no color". */
29
+ .swatchNone {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ background-color: var(--sv-color-surface-sunken);
34
+ border: 2px dashed var(--sv-color-border-strong);
35
+ color: var(--sv-color-text-muted);
36
+ }
37
+
38
+ .swatchNone.swatchSelected {
39
+ border-style: solid;
40
+ }
41
+
42
+ /* Wraps the native color input so a decorative "+" glyph can sit on top of
43
+ it while it's empty (a native `<input type="color">` can't have DOM
44
+ children of its own) and so the same `.swatchSelected` ring used above can
45
+ be applied to the wrapper without fighting the input's own default
46
+ focus/border rendering. */
47
+ .customWrapper {
48
+ position: relative;
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ width: var(--sv-space-8);
53
+ height: var(--sv-space-8);
54
+ border-radius: var(--sv-radius-md);
55
+ border: 2px solid transparent;
56
+ }
57
+
58
+ .customWrapper.swatchSelected {
59
+ /* Same visual treatment as .swatchSelected, but scoped to the wrapper —
60
+ the input itself is styled borderless below, so this is the only
61
+ border/outline actually rendered for the custom trigger. */
62
+ border-color: var(--sv-color-accent);
63
+ }
64
+
65
+ /* Before a custom color is actually chosen — same dashed/outlined treatment
66
+ as .swatchNone, since there's equally no real color value to paint yet. */
67
+ .customWrapperEmpty {
68
+ background-color: var(--sv-color-surface-sunken);
69
+ border-style: dashed;
70
+ border-color: var(--sv-color-border-strong);
71
+ }
72
+
73
+ /* Reset the native control to a plain color swatch matching the curated
74
+ ones above — browsers default `<input type="color">` to its own chrome
75
+ (a bordered box with visible padding around the swatch). */
76
+ .customInput {
77
+ width: 100%;
78
+ height: 100%;
79
+ padding: 0;
80
+ border: none;
81
+ border-radius: var(--sv-radius-md);
82
+ cursor: pointer;
83
+ background: none;
84
+ }
85
+
86
+ /* Invisible but still clickable/focusable while empty — the input always
87
+ holds *some* real hex internally (no true empty state), but that value
88
+ was never actually chosen by the user, so it must never render as this
89
+ trigger's own swatch color. Becomes visible again the moment a real
90
+ choice makes it into `value` (`.customWrapperEmpty` stops applying). */
91
+ .customWrapperEmpty .customInput {
92
+ opacity: 0;
93
+ }
94
+
95
+ .customInput:disabled {
96
+ opacity: 0.5;
97
+ cursor: not-allowed;
98
+ }
99
+
100
+ .customWrapperEmpty .customInput:disabled {
101
+ opacity: 0;
102
+ }
103
+
104
+ .customInput::-webkit-color-swatch-wrapper {
105
+ padding: 0;
106
+ border-radius: var(--sv-radius-md);
107
+ }
108
+
109
+ .customInput::-webkit-color-swatch {
110
+ border: none;
111
+ border-radius: var(--sv-radius-md);
112
+ }
113
+
114
+ .customInput::-moz-color-swatch {
115
+ border: none;
116
+ border-radius: var(--sv-radius-md);
117
+ }
118
+
119
+ /* `inset: 0` alone doesn't center this — the icon already has an explicit
120
+ width/height (its own size class), so an absolutely positioned box with
121
+ all four insets pinned to 0 resolves against top/left rather than
122
+ stretching to let flex centering do anything, landing the glyph in the
123
+ corner instead of the middle. `top/left: 50%` + `translate(-50%, -50%)`
124
+ centers a fixed-size element regardless of its own dimensions. */
125
+ .customPlusIcon {
126
+ position: absolute;
127
+ top: 50%;
128
+ left: 50%;
129
+ transform: translate(-50%, -50%);
130
+ color: var(--sv-color-text-muted);
131
+ pointer-events: none;
132
+ }
@@ -0,0 +1,80 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { ColorPicker } from './ColorPicker';
4
+
5
+ const meta = {
6
+ title: 'Components/ColorPicker',
7
+ component: ColorPicker,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'A row of curated swatch suggestions plus a native `<input type="color">` trigger for picking any color, not just the curated set — every browser\'s own full-spectrum picker, guaranteed valid hex output, no bespoke a11y work.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ swatches: [],
19
+ value: null,
20
+ onChange: () => {},
21
+ onSelectionComplete: () => {},
22
+ 'aria-label': 'Color',
23
+ },
24
+ } satisfies Meta<typeof ColorPicker>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ const SWATCHES = [
30
+ { label: 'Sky', value: '#b5c9e8' },
31
+ { label: 'Sage', value: '#c9e0c4' },
32
+ { label: 'Sand', value: '#e8d3b5' },
33
+ { label: 'Clay', value: '#e0c4c4' },
34
+ { label: 'Lilac', value: '#d8c4e0' },
35
+ { label: 'Ink', value: '#3b5166' },
36
+ { label: 'Forest', value: '#3f5c4c' },
37
+ ];
38
+
39
+ function ControlledColorPicker({
40
+ initial = null,
41
+ allowNone = false,
42
+ }: {
43
+ initial?: string | null;
44
+ allowNone?: boolean;
45
+ }) {
46
+ const [value, setValue] = useState<string | null>(initial);
47
+ return (
48
+ <ColorPicker
49
+ swatches={SWATCHES}
50
+ value={value}
51
+ onChange={setValue}
52
+ allowNone={allowNone}
53
+ aria-label="Board color"
54
+ />
55
+ );
56
+ }
57
+
58
+ export const Default: Story = {
59
+ render: () => <ControlledColorPicker initial="#c9e0c4" />,
60
+ };
61
+
62
+ export const WithNoColorOption: Story = {
63
+ render: () => <ControlledColorPicker allowNone />,
64
+ };
65
+
66
+ export const CustomColorSelected: Story = {
67
+ render: () => <ControlledColorPicker initial="#7a3fd6" />,
68
+ };
69
+
70
+ export const Disabled: Story = {
71
+ render: () => (
72
+ <ColorPicker
73
+ swatches={SWATCHES}
74
+ value="#b5c9e8"
75
+ onChange={() => {}}
76
+ aria-label="Board color"
77
+ disabled
78
+ />
79
+ ),
80
+ };
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { Icon } from '../Icon/Icon';
4
+ import styles from './ColorPicker.module.css';
5
+
6
+ export interface ColorPickerSwatch {
7
+ /** Accessible name and tooltip, e.g. "Sky". */
8
+ label: string;
9
+ /** 6-digit hex, e.g. "#b5c9e8". */
10
+ value: string;
11
+ }
12
+
13
+ export interface ColorPickerProps {
14
+ /** Curated quick-pick suggestions, in display order. */
15
+ swatches: readonly ColorPickerSwatch[];
16
+ /** Current hex value, or `null` for no color. Compared case-insensitively
17
+ * against `swatches` — a value that doesn't match any swatch renders as
18
+ * the active "custom" selection instead. */
19
+ value: string | null;
20
+ onChange: (value: string | null) => void;
21
+ /**
22
+ * Fires once after a discrete, complete pick — a swatch click or the "no
23
+ * color" click. Optional; most callers don't need it. Deliberately NOT
24
+ * fired from the native custom-color `<input type="color">`'s `onChange`
25
+ * — that can fire repeatedly while the user is still dragging inside the
26
+ * browser's own color dialog, so a caller using this to auto-close a
27
+ * wrapping `Popover` would otherwise slam it shut mid-adjustment.
28
+ */
29
+ onSelectionComplete?: () => void;
30
+ /** Shows a leading "no color" option. Off by default — most consumers
31
+ * (anything painting a fixed-size chip) always need a real color; only
32
+ * a board canvas-style consumer typically wants "none" as a real choice. */
33
+ allowNone?: boolean;
34
+ noneLabel?: string;
35
+ /** Accessible name for the native color-input trigger that lets a user
36
+ * pick any color, not just a curated swatch. */
37
+ customLabel?: string;
38
+ disabled?: boolean;
39
+ 'aria-label': string;
40
+ className?: string;
41
+ }
42
+
43
+ function normalize(hex: string): string {
44
+ return hex.toLowerCase();
45
+ }
46
+
47
+ /**
48
+ * ColorPicker — a row of curated swatch suggestions plus a native
49
+ * `<input type="color">` trigger for picking any color, not just the
50
+ * curated set. The native color input is deliberately not a custom-built
51
+ * hue/saturation picker: it already gives every browser's own full-spectrum
52
+ * picker (with an eyedropper tool in most), guarantees valid hex output, and
53
+ * needs no bespoke a11y work of its own.
54
+ *
55
+ * `value` drives which affordance reads as selected: a hex matching a
56
+ * swatch highlights that swatch; a hex matching none of them highlights the
57
+ * custom trigger instead (its own dial shows that color); `null` (only
58
+ * meaningful with `allowNone`) highlights the "no color" option.
59
+ *
60
+ * The custom trigger shows a plain "+" on a neutral dashed box until a
61
+ * genuinely custom color is active — it never previews an arbitrary dial
62
+ * color the user hasn't actually chosen. The underlying native color input
63
+ * still needs *some* real hex to hold internally at all times (it has no
64
+ * true empty state), so that starting value stays invisible (`opacity: 0`
65
+ * on the input itself) rather than painted as this trigger's own swatch.
66
+ */
67
+ export function ColorPicker({
68
+ swatches,
69
+ value,
70
+ onChange,
71
+ onSelectionComplete,
72
+ allowNone = false,
73
+ noneLabel = 'No color',
74
+ customLabel = 'Custom color',
75
+ disabled = false,
76
+ 'aria-label': ariaLabel,
77
+ className,
78
+ }: ColorPickerProps) {
79
+ const normalizedValue = value ? normalize(value) : null;
80
+ const matchedSwatch = swatches.find((s) => normalize(s.value) === normalizedValue);
81
+ const isCustomActive = normalizedValue !== null && !matchedSwatch;
82
+ // The native input always needs a real hex to display (it has no "empty"
83
+ // state) — the active custom color when there is one, otherwise a neutral
84
+ // starting point for whenever it's next opened.
85
+ const customDialValue = isCustomActive && value ? value : (swatches[0]?.value ?? '#000000');
86
+
87
+ return (
88
+ <div
89
+ className={[styles.row, className].filter(Boolean).join(' ')}
90
+ role="group"
91
+ aria-label={ariaLabel}
92
+ >
93
+ {allowNone && (
94
+ <button
95
+ type="button"
96
+ role="radio"
97
+ aria-checked={value === null}
98
+ aria-label={noneLabel}
99
+ title={noneLabel}
100
+ disabled={disabled}
101
+ className={[styles.swatch, styles.swatchNone, value === null ? styles.swatchSelected : '']
102
+ .filter(Boolean)
103
+ .join(' ')}
104
+ onClick={() => {
105
+ onChange(null);
106
+ onSelectionComplete?.();
107
+ }}
108
+ >
109
+ <Icon name="x" size="sm" aria-hidden={true} />
110
+ </button>
111
+ )}
112
+ {swatches.map((s) => (
113
+ <button
114
+ key={s.value}
115
+ type="button"
116
+ role="radio"
117
+ aria-checked={normalize(s.value) === normalizedValue}
118
+ aria-label={s.label}
119
+ title={s.label}
120
+ disabled={disabled}
121
+ className={[
122
+ styles.swatch,
123
+ normalize(s.value) === normalizedValue ? styles.swatchSelected : '',
124
+ ]
125
+ .filter(Boolean)
126
+ .join(' ')}
127
+ style={{ backgroundColor: s.value }}
128
+ onClick={() => {
129
+ onChange(s.value);
130
+ onSelectionComplete?.();
131
+ }}
132
+ />
133
+ ))}
134
+ <span
135
+ className={[
136
+ styles.customWrapper,
137
+ isCustomActive ? styles.swatchSelected : styles.customWrapperEmpty,
138
+ ]
139
+ .filter(Boolean)
140
+ .join(' ')}
141
+ >
142
+ <input
143
+ type="color"
144
+ className={styles.customInput}
145
+ aria-label={customLabel}
146
+ title={customLabel}
147
+ disabled={disabled}
148
+ value={customDialValue}
149
+ onChange={(e) => onChange(e.target.value)}
150
+ />
151
+ {!isCustomActive && (
152
+ <Icon name="plus" size="sm" aria-hidden={true} className={styles.customPlusIcon} />
153
+ )}
154
+ </span>
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { ColorPicker } from '../ColorPicker';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const SWATCHES = [
9
+ { label: 'Sky', value: '#b5c9e8' },
10
+ { label: 'Sage', value: '#c9e0c4' },
11
+ ];
12
+
13
+ describe('ColorPicker', () => {
14
+ it('renders a swatch button per entry', () => {
15
+ render(<ColorPicker swatches={SWATCHES} value={null} onChange={() => {}} aria-label="Color" />);
16
+ expect(screen.getByRole('radio', { name: 'Sky' })).toBeTruthy();
17
+ expect(screen.getByRole('radio', { name: 'Sage' })).toBeTruthy();
18
+ });
19
+
20
+ it('marks the swatch matching value as checked', () => {
21
+ render(
22
+ <ColorPicker swatches={SWATCHES} value="#c9e0c4" onChange={() => {}} aria-label="Color" />,
23
+ );
24
+ expect(screen.getByRole('radio', { name: 'Sage' }).getAttribute('aria-checked')).toBe('true');
25
+ expect(screen.getByRole('radio', { name: 'Sky' }).getAttribute('aria-checked')).toBe('false');
26
+ });
27
+
28
+ it('matches swatches case-insensitively', () => {
29
+ render(
30
+ <ColorPicker swatches={SWATCHES} value="#C9E0C4" onChange={() => {}} aria-label="Color" />,
31
+ );
32
+ expect(screen.getByRole('radio', { name: 'Sage' }).getAttribute('aria-checked')).toBe('true');
33
+ });
34
+
35
+ it('calls onChange with the swatch value when clicked', () => {
36
+ const onChange = vi.fn();
37
+ render(<ColorPicker swatches={SWATCHES} value={null} onChange={onChange} aria-label="Color" />);
38
+ fireEvent.click(screen.getByRole('radio', { name: 'Sky' }));
39
+ expect(onChange).toHaveBeenCalledWith('#b5c9e8');
40
+ });
41
+
42
+ it('does not render a "no color" option by default', () => {
43
+ render(<ColorPicker swatches={SWATCHES} value={null} onChange={() => {}} aria-label="Color" />);
44
+ expect(screen.queryByRole('radio', { name: 'No color' })).toBeNull();
45
+ });
46
+
47
+ it('renders and wires up the "no color" option when allowNone is set', () => {
48
+ const onChange = vi.fn();
49
+ render(
50
+ <ColorPicker
51
+ swatches={SWATCHES}
52
+ value="#b5c9e8"
53
+ onChange={onChange}
54
+ allowNone
55
+ aria-label="Color"
56
+ />,
57
+ );
58
+ const noneOption = screen.getByRole('radio', { name: 'No color' });
59
+ expect(noneOption.getAttribute('aria-checked')).toBe('false');
60
+ fireEvent.click(noneOption);
61
+ expect(onChange).toHaveBeenCalledWith(null);
62
+ });
63
+
64
+ it('marks "no color" as checked when value is null', () => {
65
+ render(
66
+ <ColorPicker
67
+ swatches={SWATCHES}
68
+ value={null}
69
+ onChange={() => {}}
70
+ allowNone
71
+ aria-label="Color"
72
+ />,
73
+ );
74
+ expect(screen.getByRole('radio', { name: 'No color' }).getAttribute('aria-checked')).toBe(
75
+ 'true',
76
+ );
77
+ });
78
+
79
+ it('renders the native color input trigger for custom colors', () => {
80
+ render(<ColorPicker swatches={SWATCHES} value={null} onChange={() => {}} aria-label="Color" />);
81
+ const custom = screen.getByLabelText('Custom color') as HTMLInputElement;
82
+ expect(custom.type).toBe('color');
83
+ });
84
+
85
+ it('shows the custom value on the native input when it matches no swatch', () => {
86
+ render(
87
+ <ColorPicker swatches={SWATCHES} value="#7a3fd6" onChange={() => {}} aria-label="Color" />,
88
+ );
89
+ const custom = screen.getByLabelText('Custom color') as HTMLInputElement;
90
+ expect(custom.value).toBe('#7a3fd6');
91
+ // Neither curated swatch should read as checked once a custom color is active.
92
+ expect(screen.getByRole('radio', { name: 'Sky' }).getAttribute('aria-checked')).toBe('false');
93
+ expect(screen.getByRole('radio', { name: 'Sage' }).getAttribute('aria-checked')).toBe('false');
94
+ });
95
+
96
+ it('calls onChange with the picked value when the custom input changes', () => {
97
+ const onChange = vi.fn();
98
+ render(<ColorPicker swatches={SWATCHES} value={null} onChange={onChange} aria-label="Color" />);
99
+ const custom = screen.getByLabelText('Custom color') as HTMLInputElement;
100
+ fireEvent.change(custom, { target: { value: '#123456' } });
101
+ expect(onChange).toHaveBeenCalledWith('#123456');
102
+ });
103
+
104
+ it('disables every swatch and the custom input when disabled', () => {
105
+ render(
106
+ <ColorPicker
107
+ swatches={SWATCHES}
108
+ value={null}
109
+ onChange={() => {}}
110
+ disabled
111
+ aria-label="Color"
112
+ />,
113
+ );
114
+ expect((screen.getByRole('radio', { name: 'Sky' }) as HTMLButtonElement).disabled).toBe(true);
115
+ expect((screen.getByLabelText('Custom color') as HTMLInputElement).disabled).toBe(true);
116
+ });
117
+ });
@@ -15,7 +15,7 @@
15
15
  line-height: 1.5;
16
16
  color: var(--sv-color-text-primary);
17
17
  background-color: var(--sv-color-surface);
18
- border: 1px solid var(--sv-color-border-strong);
18
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border-strong);
19
19
  border-radius: var(--sv-radius-md);
20
20
  cursor: pointer;
21
21
  transition:
@@ -61,7 +61,7 @@
61
61
  flex-shrink: 0;
62
62
  width: 100%;
63
63
  border: none;
64
- border-bottom: 1px solid var(--sv-color-border);
64
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
65
65
  background: transparent;
66
66
  color: var(--sv-color-text-primary);
67
67
  font: inherit;
@@ -13,7 +13,7 @@
13
13
  flex-shrink: 0;
14
14
  width: 100%;
15
15
  border: none;
16
- border-bottom: 1px solid var(--sv-color-border);
16
+ border-bottom: var(--sv-border-width-hairline) solid var(--sv-color-border);
17
17
  background: transparent;
18
18
  color: var(--sv-color-text-primary);
19
19
  font: inherit;
@@ -41,7 +41,7 @@
41
41
  font-size: var(--sv-font-size-xs);
42
42
  color: var(--sv-color-error-text);
43
43
  padding: var(--sv-space-2) var(--sv-space-3);
44
- border: 1px solid var(--sv-color-error-border);
44
+ border: var(--sv-border-width-hairline) solid var(--sv-color-error-border);
45
45
  border-radius: var(--sv-radius-sm);
46
46
  background-color: var(--sv-color-error-surface);
47
47
  }
@@ -70,7 +70,7 @@
70
70
  Button). Matches Button's md padding, updated alongside it (10px,
71
71
  36px rendered height). */
72
72
  padding: 10px var(--sv-space-4);
73
- border: 1px solid transparent;
73
+ border: var(--sv-border-width-hairline) solid transparent;
74
74
  border-radius: var(--sv-radius-md);
75
75
  font-family: var(--sv-font-family);
76
76
  font-size: var(--sv-font-size-sm);
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { type ReactNode, useEffect, useRef } from 'react';
4
+ import { registerOpenOverlay, unregisterOpenOverlay } from '../../overlay-shell';
4
5
  import { Button } from '../Button/Button';
5
6
  import styles from './ConfirmDialog.module.css';
6
7
 
@@ -59,6 +60,19 @@ export function ConfirmDialog({
59
60
  }: ConfirmDialogProps) {
60
61
  const dialogRef = useRef<HTMLDialogElement>(null);
61
62
 
63
+ // Registers into the same open-overlay stack Dialog/Drawer/Sheet use
64
+ // (overlay-shell.ts) purely so a Dialog this ConfirmDialog was opened
65
+ // from inside of knows it is no longer the topmost overlay, and defers
66
+ // Escape to this one instead of closing itself. ConfirmDialog does not
67
+ // otherwise adopt useOverlayFocusCapture/useOverlayScrollLock/
68
+ // useOverlayKeyboardTrap — see this component's own doc comment for why
69
+ // it stays on the native <dialog> element for everything else.
70
+ useEffect(() => {
71
+ if (!open) return;
72
+ const id = registerOpenOverlay();
73
+ return () => unregisterOpenOverlay(id);
74
+ }, [open]);
75
+
62
76
  useEffect(() => {
63
77
  const el = dialogRef.current;
64
78
  if (!el) return;
@@ -70,6 +84,13 @@ export function ConfirmDialog({
70
84
  // listener needed for that) and whenever .close() runs — including the
71
85
  // effect above reacting to `open` going false. Listening for it here is
72
86
  // what makes Esc call the caller's onClose without a manual keydown handler.
87
+ // It is also the SINGLE call site for onClose: Cancel and backdrop-click
88
+ // below call dialogRef.current?.close() rather than onClose directly, so
89
+ // every dismissal path funnels through this one 'close' listener. Calling
90
+ // onClose directly from those handlers used to double-fire it — the
91
+ // consumer's onClose sets `open` to false as documented, which re-runs the
92
+ // effect above, which calls el.close() again, firing this same 'close'
93
+ // listener a second time for what the user experienced as one click.
73
94
  useEffect(() => {
74
95
  const el = dialogRef.current;
75
96
  if (!el) return;
@@ -85,7 +106,7 @@ export function ConfirmDialog({
85
106
  className={styles.dialog}
86
107
  aria-label={title}
87
108
  onClick={(e) => {
88
- if (e.target === e.currentTarget) onClose();
109
+ if (e.target === e.currentTarget) dialogRef.current?.close();
89
110
  }}
90
111
  >
91
112
  <div className={styles.body}>
@@ -93,7 +114,12 @@ export function ConfirmDialog({
93
114
  <div className={styles.message}>{message}</div>
94
115
  {error && <p className={styles.error}>{error}</p>}
95
116
  <div className={styles.actions}>
96
- <Button type="button" variant="secondary" onClick={onClose} disabled={pending}>
117
+ <Button
118
+ type="button"
119
+ variant="secondary"
120
+ onClick={() => dialogRef.current?.close()}
121
+ disabled={pending}
122
+ >
97
123
  {cancelLabel}
98
124
  </Button>
99
125
  {destructive ? (