@raydenui/ui 0.9.7 → 0.10.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 (309) hide show
  1. package/README.md +124 -49
  2. package/dist/Table-DCNSxua1.d.cts +31 -0
  3. package/dist/Table-DCNSxua1.d.ts +31 -0
  4. package/dist/blocks/ApplicationShellBlock.cjs +157 -0
  5. package/dist/blocks/ApplicationShellBlock.js +154 -0
  6. package/dist/blocks/CheckoutReviewBlock.cjs +16 -0
  7. package/dist/blocks/CheckoutReviewBlock.js +13 -0
  8. package/dist/blocks/CommandPaletteBlock.cjs +334 -0
  9. package/dist/blocks/CommandPaletteBlock.js +331 -0
  10. package/dist/blocks/CreateAccountBlock.cjs +147 -0
  11. package/dist/blocks/CreateAccountBlock.js +143 -0
  12. package/dist/blocks/EmptyStateBlock.cjs +19 -0
  13. package/dist/blocks/EmptyStateBlock.js +16 -0
  14. package/dist/blocks/FeatureOverviewBlock.cjs +54 -0
  15. package/dist/blocks/FeatureOverviewBlock.js +51 -0
  16. package/dist/blocks/HeaderBlock.cjs +230 -0
  17. package/dist/blocks/HeaderBlock.js +227 -0
  18. package/dist/blocks/KpiOverviewBlock.cjs +63 -0
  19. package/dist/blocks/KpiOverviewBlock.js +60 -0
  20. package/dist/blocks/LoginBlock.cjs +66 -0
  21. package/dist/blocks/LoginBlock.js +63 -0
  22. package/dist/blocks/NotificationsBlock.cjs +17 -0
  23. package/dist/blocks/NotificationsBlock.js +14 -0
  24. package/dist/blocks/PageHeaderBlock.cjs +121 -0
  25. package/dist/blocks/PageHeaderBlock.js +118 -0
  26. package/dist/blocks/PricingPlansBlock.cjs +146 -0
  27. package/dist/blocks/PricingPlansBlock.js +143 -0
  28. package/dist/blocks/ProductCollectionBlock.cjs +37 -0
  29. package/dist/blocks/ProductCollectionBlock.js +34 -0
  30. package/dist/blocks/ProductDetailBlock.cjs +30 -0
  31. package/dist/blocks/ProductDetailBlock.js +27 -0
  32. package/dist/blocks/ProductHeroBlock.cjs +83 -0
  33. package/dist/blocks/ProductHeroBlock.js +80 -0
  34. package/dist/blocks/ProfileSettingsBlock.cjs +130 -0
  35. package/dist/blocks/ProfileSettingsBlock.js +126 -0
  36. package/dist/blocks/QuickSendBlock.cjs +11 -0
  37. package/dist/blocks/QuickSendBlock.js +8 -0
  38. package/dist/blocks/RecentTransactionsBlock.cjs +13 -0
  39. package/dist/blocks/RecentTransactionsBlock.js +10 -0
  40. package/dist/blocks/SearchableTableBlock.cjs +91 -0
  41. package/dist/blocks/SearchableTableBlock.js +88 -0
  42. package/dist/blocks/ShoppingCartBlock.cjs +16 -0
  43. package/dist/blocks/ShoppingCartBlock.js +13 -0
  44. package/dist/blocks/SiteFooterBlock.cjs +69 -0
  45. package/dist/blocks/SiteFooterBlock.js +66 -0
  46. package/dist/blocks/TableBlock.cjs +20 -0
  47. package/dist/blocks/TableBlock.js +17 -0
  48. package/dist/blocks/TaskListBlock.cjs +198 -0
  49. package/dist/blocks/TaskListBlock.js +195 -0
  50. package/dist/blocks/WorkspaceSwitcherBlock.cjs +291 -0
  51. package/dist/blocks/WorkspaceSwitcherBlock.js +288 -0
  52. package/dist/blocks/commerce.cjs +44 -0
  53. package/dist/blocks/commerce.js +35 -0
  54. package/dist/blocks/index.cjs +53 -0
  55. package/dist/blocks/index.js +24 -0
  56. package/dist/blocks.d.cts +1820 -0
  57. package/dist/blocks.d.ts +1820 -0
  58. package/dist/chart.cjs +13 -0
  59. package/dist/chart.d.cts +59 -0
  60. package/dist/chart.d.ts +59 -0
  61. package/dist/chart.js +5 -0
  62. package/dist/components/Accordion/Accordion.cjs +94 -0
  63. package/dist/components/Accordion/Accordion.js +91 -0
  64. package/dist/components/Accordion/index.cjs +8 -0
  65. package/dist/components/Accordion/index.js +1 -0
  66. package/dist/components/ActivityFeed/ActivityContent.cjs +40 -0
  67. package/dist/components/ActivityFeed/ActivityContent.js +37 -0
  68. package/dist/components/ActivityFeed/ActivityItem.cjs +19 -0
  69. package/dist/components/ActivityFeed/ActivityItem.js +16 -0
  70. package/dist/components/ActivityFeed/index.cjs +7 -0
  71. package/dist/components/ActivityFeed/index.js +2 -0
  72. package/dist/components/Alert/Alert.cjs +42 -0
  73. package/dist/components/Alert/Alert.js +39 -0
  74. package/dist/components/Alert/index.cjs +5 -0
  75. package/dist/components/Alert/index.js +1 -0
  76. package/dist/components/Avatar/Avatar.cjs +101 -0
  77. package/dist/components/Avatar/Avatar.js +98 -0
  78. package/dist/components/Avatar/index.cjs +6 -0
  79. package/dist/components/Avatar/index.js +1 -0
  80. package/dist/components/Badge/Badge.cjs +53 -0
  81. package/dist/components/Badge/Badge.js +50 -0
  82. package/dist/components/Badge/index.cjs +5 -0
  83. package/dist/components/Badge/index.js +1 -0
  84. package/dist/components/Banner/Banner.cjs +83 -0
  85. package/dist/components/Banner/Banner.js +80 -0
  86. package/dist/components/Banner/index.cjs +5 -0
  87. package/dist/components/Banner/index.js +1 -0
  88. package/dist/components/Breadcrumb/Breadcrumb.cjs +31 -0
  89. package/dist/components/Breadcrumb/Breadcrumb.js +28 -0
  90. package/dist/components/Breadcrumb/index.cjs +5 -0
  91. package/dist/components/Breadcrumb/index.js +1 -0
  92. package/dist/components/Button/Button.cjs +88 -0
  93. package/dist/components/Button/Button.js +85 -0
  94. package/dist/components/Button/index.cjs +5 -0
  95. package/dist/components/Button/index.js +1 -0
  96. package/dist/components/ButtonGroup/ButtonGroup.cjs +8 -0
  97. package/dist/components/ButtonGroup/ButtonGroup.js +5 -0
  98. package/dist/components/ButtonGroup/ButtonGroupItem.cjs +15 -0
  99. package/dist/components/ButtonGroup/ButtonGroupItem.js +12 -0
  100. package/dist/components/ButtonGroup/index.cjs +7 -0
  101. package/dist/components/ButtonGroup/index.js +2 -0
  102. package/dist/components/Card/Card.cjs +94 -0
  103. package/dist/components/Card/Card.js +91 -0
  104. package/dist/components/Card/index.cjs +9 -0
  105. package/dist/components/Card/index.js +1 -0
  106. package/dist/components/Chart/Chart.cjs +160 -0
  107. package/dist/components/Chart/Chart.js +153 -0
  108. package/dist/components/Chart/index.cjs +10 -0
  109. package/dist/components/Chart/index.js +2 -0
  110. package/dist/components/Chart/theme.cjs +67 -0
  111. package/dist/components/Chart/theme.js +62 -0
  112. package/dist/components/Chip/Chip.cjs +18 -0
  113. package/dist/components/Chip/Chip.js +15 -0
  114. package/dist/components/Chip/index.cjs +5 -0
  115. package/dist/components/Chip/index.js +1 -0
  116. package/dist/components/Counter/Counter.cjs +74 -0
  117. package/dist/components/Counter/Counter.js +71 -0
  118. package/dist/components/Counter/index.cjs +6 -0
  119. package/dist/components/Counter/index.js +1 -0
  120. package/dist/components/DatePicker/DatePicker.cjs +343 -0
  121. package/dist/components/DatePicker/DatePicker.js +340 -0
  122. package/dist/components/DatePicker/index.cjs +5 -0
  123. package/dist/components/DatePicker/index.js +1 -0
  124. package/dist/components/Divider/Divider.cjs +31 -0
  125. package/dist/components/Divider/Divider.js +28 -0
  126. package/dist/components/Divider/index.cjs +5 -0
  127. package/dist/components/Divider/index.js +1 -0
  128. package/dist/components/DropdownMenu/DropdownMenu.cjs +108 -0
  129. package/dist/components/DropdownMenu/DropdownMenu.js +105 -0
  130. package/dist/components/DropdownMenu/index.cjs +11 -0
  131. package/dist/components/DropdownMenu/index.js +1 -0
  132. package/dist/components/EmptyStateIllustration/EmptyStateIllustration.cjs +42 -0
  133. package/dist/components/EmptyStateIllustration/EmptyStateIllustration.js +39 -0
  134. package/dist/components/EmptyStateIllustration/illustrations.cjs +3322 -0
  135. package/dist/components/EmptyStateIllustration/illustrations.js +3319 -0
  136. package/dist/components/EmptyStateIllustration/index.cjs +7 -0
  137. package/dist/components/EmptyStateIllustration/index.js +2 -0
  138. package/dist/components/FileUpload/FileUpload.cjs +116 -0
  139. package/dist/components/FileUpload/FileUpload.js +113 -0
  140. package/dist/components/FileUpload/FileUploadDropZone.cjs +58 -0
  141. package/dist/components/FileUpload/FileUploadDropZone.js +55 -0
  142. package/dist/components/FileUpload/FileUploadIcons.cjs +55 -0
  143. package/dist/components/FileUpload/FileUploadIcons.js +50 -0
  144. package/dist/components/FileUpload/FileUploadItem.cjs +34 -0
  145. package/dist/components/FileUpload/FileUploadItem.js +31 -0
  146. package/dist/components/FileUpload/index.cjs +9 -0
  147. package/dist/components/FileUpload/index.js +3 -0
  148. package/dist/components/FormControl/Checkbox.cjs +27 -0
  149. package/dist/components/FormControl/Checkbox.js +24 -0
  150. package/dist/components/FormControl/Radio.cjs +15 -0
  151. package/dist/components/FormControl/Radio.js +12 -0
  152. package/dist/components/FormControl/Toggle.cjs +15 -0
  153. package/dist/components/FormControl/Toggle.js +12 -0
  154. package/dist/components/FormControl/index.cjs +9 -0
  155. package/dist/components/FormControl/index.js +3 -0
  156. package/dist/components/Icon/Icon.cjs +70 -0
  157. package/dist/components/Icon/Icon.js +34 -0
  158. package/dist/components/Icon/catalog.cjs +1672 -0
  159. package/dist/components/Icon/catalog.js +1669 -0
  160. package/dist/components/Icon/icons.cjs +5621 -0
  161. package/dist/components/Icon/icons.js +5610 -0
  162. package/dist/components/Icon/index.cjs +8 -0
  163. package/dist/components/Icon/index.js +2 -0
  164. package/dist/components/Input/Input.cjs +64 -0
  165. package/dist/components/Input/Input.js +61 -0
  166. package/dist/components/Input/index.cjs +5 -0
  167. package/dist/components/Input/index.js +1 -0
  168. package/dist/components/MetricsCard/MetricsCard.cjs +107 -0
  169. package/dist/components/MetricsCard/MetricsCard.js +104 -0
  170. package/dist/components/MetricsCard/index.cjs +5 -0
  171. package/dist/components/MetricsCard/index.js +1 -0
  172. package/dist/components/Modal/Modal.cjs +126 -0
  173. package/dist/components/Modal/Modal.js +123 -0
  174. package/dist/components/Modal/index.cjs +5 -0
  175. package/dist/components/Modal/index.js +1 -0
  176. package/dist/components/Pagination/Pagination.cjs +42 -0
  177. package/dist/components/Pagination/Pagination.js +39 -0
  178. package/dist/components/Pagination/index.cjs +5 -0
  179. package/dist/components/Pagination/index.js +1 -0
  180. package/dist/components/ProgressBar/ProgressBar.cjs +34 -0
  181. package/dist/components/ProgressBar/ProgressBar.js +31 -0
  182. package/dist/components/ProgressBar/index.cjs +5 -0
  183. package/dist/components/ProgressBar/index.js +1 -0
  184. package/dist/components/ProgressCircle/ProgressCircle.cjs +34 -0
  185. package/dist/components/ProgressCircle/ProgressCircle.js +31 -0
  186. package/dist/components/ProgressCircle/index.cjs +5 -0
  187. package/dist/components/ProgressCircle/index.js +1 -0
  188. package/dist/components/Select/Select.cjs +84 -0
  189. package/dist/components/Select/Select.js +80 -0
  190. package/dist/components/Select/SelectOption.cjs +26 -0
  191. package/dist/components/Select/SelectOption.js +23 -0
  192. package/dist/components/Select/index.cjs +7 -0
  193. package/dist/components/Select/index.js +2 -0
  194. package/dist/components/SidebarMenu/SidebarMenu.cjs +85 -0
  195. package/dist/components/SidebarMenu/SidebarMenu.js +81 -0
  196. package/dist/components/SidebarMenu/SidebarMenuItem.cjs +126 -0
  197. package/dist/components/SidebarMenu/SidebarMenuItem.js +122 -0
  198. package/dist/components/SidebarMenu/SidebarMenuSection.cjs +14 -0
  199. package/dist/components/SidebarMenu/SidebarMenuSection.js +11 -0
  200. package/dist/components/SidebarMenu/index.cjs +12 -0
  201. package/dist/components/SidebarMenu/index.js +4 -0
  202. package/dist/components/Slider/Slider.cjs +161 -0
  203. package/dist/components/Slider/Slider.js +158 -0
  204. package/dist/components/Slider/index.cjs +6 -0
  205. package/dist/components/Slider/index.js +1 -0
  206. package/dist/components/Spinner/Spinner.cjs +93 -0
  207. package/dist/components/Spinner/Spinner.js +90 -0
  208. package/dist/components/Spinner/index.cjs +5 -0
  209. package/dist/components/Spinner/index.js +1 -0
  210. package/dist/components/Stepper/Stepper.cjs +82 -0
  211. package/dist/components/Stepper/Stepper.js +79 -0
  212. package/dist/components/Stepper/index.cjs +7 -0
  213. package/dist/components/Stepper/index.js +1 -0
  214. package/dist/components/Table/Table.cjs +25 -0
  215. package/dist/components/Table/Table.js +22 -0
  216. package/dist/components/Table/index.cjs +10 -0
  217. package/dist/components/Table/index.js +1 -0
  218. package/dist/components/Table/useTableSelection.cjs +60 -0
  219. package/dist/components/Table/useTableSelection.js +57 -0
  220. package/dist/components/Tabs/Tab.cjs +70 -0
  221. package/dist/components/Tabs/Tab.js +67 -0
  222. package/dist/components/Tabs/Tabs.cjs +133 -0
  223. package/dist/components/Tabs/Tabs.js +129 -0
  224. package/dist/components/Tabs/index.cjs +7 -0
  225. package/dist/components/Tabs/index.js +2 -0
  226. package/dist/components/Tooltip/Tooltip.cjs +101 -0
  227. package/dist/components/Tooltip/Tooltip.js +98 -0
  228. package/dist/components/Tooltip/index.cjs +5 -0
  229. package/dist/components/Tooltip/index.js +1 -0
  230. package/dist/context/ThemeContext.cjs +97 -0
  231. package/dist/context/ThemeContext.js +92 -0
  232. package/dist/fonts/README.md +13 -0
  233. package/dist/fonts/hanken-grotesk-cyrillic-ext-italic.woff2 +0 -0
  234. package/dist/fonts/hanken-grotesk-cyrillic-ext-normal.woff2 +0 -0
  235. package/dist/fonts/hanken-grotesk-latin-ext-italic.woff2 +0 -0
  236. package/dist/fonts/hanken-grotesk-latin-ext-normal.woff2 +0 -0
  237. package/dist/fonts/hanken-grotesk-latin-italic.woff2 +0 -0
  238. package/dist/fonts/hanken-grotesk-latin-normal.woff2 +0 -0
  239. package/dist/fonts/hanken-grotesk-vietnamese-italic.woff2 +0 -0
  240. package/dist/fonts/hanken-grotesk-vietnamese-normal.woff2 +0 -0
  241. package/dist/fonts/hankengrotesk-LICENSE.txt +94 -0
  242. package/dist/fonts/manrope-LICENSE.txt +93 -0
  243. package/dist/fonts/manrope-cyrillic-ext-normal.woff2 +0 -0
  244. package/dist/fonts/manrope-cyrillic-normal.woff2 +0 -0
  245. package/dist/fonts/manrope-greek-normal.woff2 +0 -0
  246. package/dist/fonts/manrope-latin-ext-normal.woff2 +0 -0
  247. package/dist/fonts/manrope-latin-normal.woff2 +0 -0
  248. package/dist/fonts/manrope-vietnamese-normal.woff2 +0 -0
  249. package/dist/fonts.css +148 -0
  250. package/dist/hooks/form/index.cjs +11 -0
  251. package/dist/hooks/form/index.js +3 -0
  252. package/dist/hooks/form/useRaydenFormControl.cjs +81 -0
  253. package/dist/hooks/form/useRaydenFormControl.js +76 -0
  254. package/dist/hooks/form/useRaydenInput.cjs +38 -0
  255. package/dist/hooks/form/useRaydenInput.js +35 -0
  256. package/dist/hooks/form/useRaydenSelect.cjs +45 -0
  257. package/dist/hooks/form/useRaydenSelect.js +42 -0
  258. package/dist/hooks/index.cjs +17 -0
  259. package/dist/hooks/index.js +1 -0
  260. package/dist/hooks/useBodyScrollLock.cjs +24 -0
  261. package/dist/hooks/useBodyScrollLock.js +21 -0
  262. package/dist/hooks/useCollisionAwareSide.cjs +72 -0
  263. package/dist/hooks/useCollisionAwareSide.js +69 -0
  264. package/dist/hooks/useControllableValue.cjs +16 -0
  265. package/dist/hooks/useControllableValue.js +13 -0
  266. package/dist/hooks/useDismissableLayer.cjs +100 -0
  267. package/dist/hooks/useDismissableLayer.js +97 -0
  268. package/dist/hooks/usePopupList.cjs +101 -0
  269. package/dist/hooks/usePopupList.js +98 -0
  270. package/dist/icons-8yD7I5jO.d.cts +430 -0
  271. package/dist/icons-8yD7I5jO.d.ts +430 -0
  272. package/dist/icons.cjs +2111 -418
  273. package/dist/icons.d.cts +1256 -429
  274. package/dist/icons.d.ts +1256 -429
  275. package/dist/icons.js +2089 -1
  276. package/dist/index.cjs +128 -15046
  277. package/dist/index.d.cts +697 -662
  278. package/dist/index.d.ts +697 -662
  279. package/dist/index.js +43 -14962
  280. package/dist/motion/Collapse.cjs +28 -0
  281. package/dist/motion/Collapse.js +25 -0
  282. package/dist/motion/MotionProvider.cjs +43 -0
  283. package/dist/motion/MotionProvider.js +38 -0
  284. package/dist/motion/Pressable.cjs +46 -0
  285. package/dist/motion/Pressable.js +43 -0
  286. package/dist/motion/Reveal.cjs +19 -0
  287. package/dist/motion/Reveal.js +16 -0
  288. package/dist/motion/SharedLayout.cjs +46 -0
  289. package/dist/motion/SharedLayout.js +43 -0
  290. package/dist/motion/index.cjs +19 -0
  291. package/dist/motion/index.js +6 -0
  292. package/dist/motion/presence.cjs +58 -0
  293. package/dist/motion/presence.js +55 -0
  294. package/dist/motion/presets.cjs +88 -0
  295. package/dist/motion/presets.js +84 -0
  296. package/dist/motion.d.cts +51 -0
  297. package/dist/motion.d.ts +51 -0
  298. package/dist/preset.cjs +356 -195
  299. package/dist/preset.d.cts +111 -32
  300. package/dist/preset.d.ts +111 -32
  301. package/dist/preset.js +353 -187
  302. package/dist/presets-Ba5QndHg.d.cts +144 -0
  303. package/dist/presets-Ba5QndHg.d.ts +144 -0
  304. package/dist/styles.css +1 -1
  305. package/dist/utils/cn.cjs +38 -0
  306. package/dist/utils/cn.js +35 -0
  307. package/dist/utils/resolveIcon.cjs +18 -0
  308. package/dist/utils/resolveIcon.js +15 -0
  309. package/package.json +79 -21
@@ -0,0 +1,291 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorkspaceSwitcherBlock = WorkspaceSwitcherBlock;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const cn_1 = require("../utils/cn.cjs");
7
+ const Icon_1 = require("../components/Icon/index.cjs");
8
+ const Spinner_1 = require("../components/Spinner/index.cjs");
9
+ /* ─── Recipes ────────────────────────────────────────────────────────── */
10
+ const FOCUS_RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-action-primary-text";
11
+ /* ─── Logo ───────────────────────────────────────────────────────────── */
12
+ function WorkspaceMark({ item, size = "md", }) {
13
+ const box = size === "sm" ? "size-7 text-body-xs" : "size-9 text-body-sm";
14
+ if (item.logoSrc) {
15
+ return ((0, jsx_runtime_1.jsx)("img", { src: item.logoSrc, alt: "", className: (0, cn_1.cn)("shrink-0 rounded-lg border border-surface-border object-cover", box) }));
16
+ }
17
+ return ((0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true",
18
+ // The colour comes last: `box` carries a `text-body-*` size utility, and
19
+ // class merging treats it as the same group, so a colour written first is
20
+ // dropped and the initials fall back to the inherited foreground.
21
+ className: (0, cn_1.cn)("inline-flex shrink-0 items-center justify-center rounded-lg bg-primary-50 font-semibold", box, "text-action-primary-text"), children: item.icon ? ((0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: item.icon, size: size === "sm" ? "sm" : "md" })) : ((item.initials ?? item.name.slice(0, 2).toUpperCase())) }));
22
+ }
23
+ /* ─── Component ──────────────────────────────────────────────────────── */
24
+ function WorkspaceSwitcherBlock({ current, workspaces = [], onSelect, unavailableMessage = "Switching to this workspace is not available.", status = "idle", errorMessage = "Your workspaces could not be loaded.", onRetry, loadingMessage = "Loading workspaces", triggerLabel = "Switch workspace", triggerHint, placeholder = "Choose a workspace", panelLabel = "Workspaces", filterLabel = "Filter workspaces", filterPlaceholder = "Type to filter", filterThreshold = 6, emptyTitle = "You are not in any workspace yet", emptyDescription = "Create a workspace or ask an administrator for an invitation.", noMatchesTitle = "No workspace matches that filter", noMatchesDescription = "Check the spelling, or clear the filter to see every workspace.", footerActions = [], resultCountLabel = (count) => (count === 1 ? "1 workspace matches" : `${count} workspaces match`), defaultOpen = false, open: controlledOpen, onOpenChange, className, }) {
25
+ const uid = (0, react_1.useId)();
26
+ const panelId = `${uid}-panel`;
27
+ const listId = `${uid}-list`;
28
+ const filterId = `${uid}-filter`;
29
+ const [uncontrolledOpen, setUncontrolledOpen] = (0, react_1.useState)(defaultOpen);
30
+ const open = controlledOpen ?? uncontrolledOpen;
31
+ const [query, setQuery] = (0, react_1.useState)("");
32
+ /** Roving tabindex: exactly one option is in the tab order at a time. */
33
+ const [activeIndex, setActiveIndex] = (0, react_1.useState)(0);
34
+ const rootRef = (0, react_1.useRef)(null);
35
+ const triggerRef = (0, react_1.useRef)(null);
36
+ /**
37
+ * The popover is wider than its trigger, so a trigger sitting near the right
38
+ * edge would push the popover past it and widen the document. The offset is
39
+ * measured from the trigger when the popover opens: start-aligned when it
40
+ * fits, end-aligned when it does not, and pinned to the viewport when neither
41
+ * side fits. Nothing is positioned until it is measured.
42
+ */
43
+ const [panelBox, setPanelBox] = (0, react_1.useState)(null);
44
+ const filterRef = (0, react_1.useRef)(null);
45
+ const listRef = (0, react_1.useRef)(null);
46
+ const setOpen = (0, react_1.useCallback)((next) => {
47
+ if (controlledOpen === undefined)
48
+ setUncontrolledOpen(next);
49
+ onOpenChange?.(next);
50
+ }, [controlledOpen, onOpenChange]);
51
+ const close = (0, react_1.useCallback)((restoreFocus) => {
52
+ setOpen(false);
53
+ if (restoreFocus)
54
+ triggerRef.current?.focus();
55
+ }, [setOpen]);
56
+ /* Closing when the pointer lands outside keeps the popover from shadowing the
57
+ page; it is deliberately not a modal, so focus is never trapped here. */
58
+ (0, react_1.useEffect)(() => {
59
+ if (!open)
60
+ return;
61
+ function handlePointerDown(event) {
62
+ if (!rootRef.current)
63
+ return;
64
+ if (!rootRef.current.contains(event.target))
65
+ setOpen(false);
66
+ }
67
+ document.addEventListener("mousedown", handlePointerDown);
68
+ return () => document.removeEventListener("mousedown", handlePointerDown);
69
+ }, [open, setOpen]);
70
+ (0, react_1.useEffect)(() => {
71
+ if (!open) {
72
+ setPanelBox(null);
73
+ return;
74
+ }
75
+ function place() {
76
+ const rect = triggerRef.current?.getBoundingClientRect();
77
+ if (!rect)
78
+ return;
79
+ const viewport = document.documentElement.clientWidth;
80
+ const width = Math.min(320, Math.max(rect.width, viewport - 24));
81
+ let left = 0;
82
+ if (rect.left + width > viewport - 12) {
83
+ left = rect.width - width;
84
+ if (rect.left + left < 12)
85
+ left = 12 - rect.left;
86
+ }
87
+ setPanelBox({ left, width });
88
+ }
89
+ place();
90
+ window.addEventListener("resize", place);
91
+ // A window resize is not the only thing that moves the trigger: a root text
92
+ // size change, a sidebar collapsing, or a container query flipping all do it
93
+ // without firing `resize`.
94
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => place());
95
+ if (observer && triggerRef.current)
96
+ observer.observe(triggerRef.current);
97
+ if (observer)
98
+ observer.observe(document.documentElement);
99
+ return () => {
100
+ window.removeEventListener("resize", place);
101
+ observer?.disconnect();
102
+ };
103
+ }, [open]);
104
+ /* The filter is where a keyboard user expects to land, and it is where typing
105
+ already works, so it takes focus as the popover opens. */
106
+ (0, react_1.useEffect)(() => {
107
+ if (!open)
108
+ return;
109
+ const target = filterRef.current ?? listRef.current?.querySelector("[data-option]");
110
+ target?.focus();
111
+ }, [open]);
112
+ (0, react_1.useEffect)(() => {
113
+ if (!open)
114
+ setQuery("");
115
+ }, [open]);
116
+ (0, react_1.useEffect)(() => {
117
+ setActiveIndex(0);
118
+ }, [query, open]);
119
+ const matches = (0, react_1.useMemo)(() => {
120
+ const needle = query.trim().toLocaleLowerCase();
121
+ if (!needle)
122
+ return workspaces;
123
+ return workspaces.filter((item) => `${item.name} ${item.detail ?? ""}`.toLocaleLowerCase().includes(needle));
124
+ }, [query, workspaces]);
125
+ const showFilter = workspaces.length >= filterThreshold;
126
+ const hasSource = workspaces.length > 0;
127
+ /* ── Roving focus across the options ─────────────────────────────── */
128
+ function optionNodes() {
129
+ if (!listRef.current)
130
+ return [];
131
+ return Array.from(listRef.current.querySelectorAll("[data-option]"));
132
+ }
133
+ function focusOptionAt(index) {
134
+ const nodes = optionNodes();
135
+ if (nodes.length === 0)
136
+ return;
137
+ const wrapped = ((index % nodes.length) + nodes.length) % nodes.length;
138
+ setActiveIndex(wrapped);
139
+ nodes[wrapped]?.focus();
140
+ }
141
+ function handleFilterKeyDown(event) {
142
+ if (event.key === "ArrowDown") {
143
+ event.preventDefault();
144
+ focusOptionAt(0);
145
+ }
146
+ else if (event.key === "ArrowUp") {
147
+ event.preventDefault();
148
+ focusOptionAt(-1);
149
+ }
150
+ else if (event.key === "Escape") {
151
+ event.preventDefault();
152
+ close(true);
153
+ }
154
+ }
155
+ function handleOptionKeyDown(event, index) {
156
+ switch (event.key) {
157
+ case "ArrowDown":
158
+ event.preventDefault();
159
+ focusOptionAt(index + 1);
160
+ break;
161
+ case "ArrowUp":
162
+ event.preventDefault();
163
+ focusOptionAt(index - 1);
164
+ break;
165
+ case "Home":
166
+ event.preventDefault();
167
+ focusOptionAt(0);
168
+ break;
169
+ case "End":
170
+ event.preventDefault();
171
+ focusOptionAt(-1);
172
+ break;
173
+ case "Escape":
174
+ event.preventDefault();
175
+ close(true);
176
+ break;
177
+ default:
178
+ break;
179
+ }
180
+ }
181
+ function handleTriggerKeyDown(event) {
182
+ if (event.key === "ArrowDown" && !open) {
183
+ event.preventDefault();
184
+ setOpen(true);
185
+ }
186
+ }
187
+ function choose(item) {
188
+ if ("unavailableReason" in item && item.unavailableReason)
189
+ return;
190
+ if (!onSelect)
191
+ return;
192
+ onSelect(item.id);
193
+ close(true);
194
+ }
195
+ /* ── Option ──────────────────────────────────────────────────────── */
196
+ function renderOption(item, index) {
197
+ const unavailable = ("unavailableReason" in item ? item.unavailableReason : undefined) ||
198
+ (!item.href && !onSelect ? unavailableMessage : undefined);
199
+ const isCurrent = current?.id === item.id;
200
+ const reasonId = `${uid}-${item.id}-reason`;
201
+ const body = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(WorkspaceMark, { item: item, size: "sm" }), (0, jsx_runtime_1.jsxs)("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5 text-left", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-body-sm font-medium break-words", children: item.name }), item.detail && ((0, jsx_runtime_1.jsx)("span", { className: "text-body-xs break-words text-grey-500", children: item.detail })), unavailable && ((0, jsx_runtime_1.jsx)("span", { id: reasonId, className: "text-body-xs break-words text-grey-500", children: unavailable }))] }), isCurrent && ((0, jsx_runtime_1.jsxs)("span", { className: "ml-auto inline-flex shrink-0 items-center gap-1 text-body-xs font-semibold text-action-primary-text", children: [(0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "check", size: "sm" }), (0, jsx_runtime_1.jsx)("span", { children: "Current" })] }))] }));
202
+ /**
203
+ * The current entry is marked by the "Current" chip, not by a tint. An
204
+ * earlier draft tinted the row `bg-primary-50`; the muted detail line then
205
+ * measured 4.28:1 on it, below AA, which is the same mistake the Batch A
206
+ * pricing cards made with `opacity-90`. `surface-muted` is a ground the
207
+ * muted foreground role is already paired with in both modes.
208
+ */
209
+ const classes = (0, cn_1.cn)("flex min-h-11 w-full min-w-0 items-start gap-3 rounded-lg px-2 py-2 transition-colors", FOCUS_RING, unavailable
210
+ ? "cursor-not-allowed text-grey-500"
211
+ : "cursor-pointer text-grey-900 hover:bg-surface-muted", isCurrent && !unavailable && "bg-surface-muted");
212
+ const shared = {
213
+ "data-option": "true",
214
+ role: "option",
215
+ tabIndex: index === activeIndex ? 0 : -1,
216
+ "aria-selected": isCurrent,
217
+ onKeyDown: (event) => handleOptionKeyDown(event, index),
218
+ onFocus: () => setActiveIndex(index),
219
+ className: classes,
220
+ };
221
+ if (unavailable) {
222
+ return ((0, jsx_runtime_1.jsx)("div", { ...shared, "aria-disabled": "true", "aria-describedby": reasonId, children: body }, item.id));
223
+ }
224
+ if (item.href) {
225
+ // A workspace that is its own address stays a real anchor; the listbox
226
+ // role does not remove the href, so middle-click still opens a new tab.
227
+ return ((0, jsx_runtime_1.jsx)("a", { ...shared, href: item.href, onClick: () => {
228
+ onSelect?.(item.id);
229
+ close(false);
230
+ }, children: body }, item.id));
231
+ }
232
+ return ((0, jsx_runtime_1.jsx)("div", { ...shared, onClick: () => choose(item), onKeyDown: (event) => {
233
+ if (event.key === "Enter" || event.key === " ") {
234
+ event.preventDefault();
235
+ choose(item);
236
+ return;
237
+ }
238
+ handleOptionKeyDown(event, index);
239
+ }, children: body }, item.id));
240
+ }
241
+ /* ── Panel body ──────────────────────────────────────────────────── */
242
+ function renderBody() {
243
+ if (status === "loading") {
244
+ return ((0, jsx_runtime_1.jsxs)("p", { role: "status", className: "flex items-center gap-2 px-2 py-6 text-body-sm text-grey-500", children: [(0, jsx_runtime_1.jsx)(Spinner_1.Spinner, { size: "sm" }), (0, jsx_runtime_1.jsx)("span", { children: loadingMessage })] }));
245
+ }
246
+ if (status === "error") {
247
+ return ((0, jsx_runtime_1.jsxs)("div", { role: "alert", className: "m-1 flex flex-col gap-2 rounded-lg border border-error-200 bg-error-50 px-3 py-3 text-body-sm text-error-700", children: [(0, jsx_runtime_1.jsx)("p", { className: "break-words", children: errorMessage }), onRetry && ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: onRetry, className: (0, cn_1.cn)("inline-flex min-h-9 w-fit cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-current px-3 py-1.5 font-semibold", FOCUS_RING), children: [(0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "refresh", size: "sm" }), "Try again"] }))] }));
248
+ }
249
+ if (!hasSource) {
250
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1 px-2 py-6", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-body-sm font-semibold break-words text-grey-900", children: emptyTitle }), (0, jsx_runtime_1.jsx)("p", { className: "text-body-sm break-words text-grey-500", children: emptyDescription })] }));
251
+ }
252
+ if (matches.length === 0) {
253
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1 px-2 py-6", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-body-sm font-semibold break-words text-grey-900", children: noMatchesTitle }), (0, jsx_runtime_1.jsx)("p", { className: "text-body-sm break-words text-grey-500", children: noMatchesDescription })] }));
254
+ }
255
+ return ((0, jsx_runtime_1.jsx)("div", { ref: listRef, role: "listbox", "aria-label": panelLabel, className: "flex flex-col gap-0.5 p-1", children: matches.map((item, index) => renderOption(item, index)) }));
256
+ }
257
+ /* ── Trigger ─────────────────────────────────────────────────────── */
258
+ /**
259
+ * The spoken name repeats every visible word in the order it is drawn, so a
260
+ * voice-control user can say what they can see (WCAG 2.5.3), and then adds
261
+ * the purpose the icon alone would not carry.
262
+ */
263
+ const triggerName = current
264
+ ? `${triggerHint ? `${triggerHint}, ` : ""}${current.name}. ${triggerLabel}`
265
+ : `${placeholder}. ${triggerLabel}`;
266
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef,
267
+ /**
268
+ * Deliberately NOT an `@container`. `container-type: inline-size` makes an
269
+ * element's inline size independent of its own contents, so this root —
270
+ * a shrinkable flex item inside the shell's top bar — resolved to 0px at
271
+ * every width and the switcher disappeared. The block has no internal
272
+ * container query to justify the cost: the popover is placed from a
273
+ * measurement instead.
274
+ */
275
+ className: (0, cn_1.cn)("relative w-full min-w-0", className), children: [(0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, "aria-label": triggerName, onClick: () => setOpen(!open), onKeyDown: handleTriggerKeyDown, className: (0, cn_1.cn)("flex min-h-11 w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border border-surface-border bg-surface px-2 py-1.5 text-left text-grey-700 transition-colors hover:bg-surface-muted", FOCUS_RING), children: [current ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(WorkspaceMark, { item: current, size: "sm" }), (0, jsx_runtime_1.jsxs)("span", { className: "flex min-w-0 flex-col", children: [triggerHint && (0, jsx_runtime_1.jsx)("span", { className: "text-body-xs text-grey-500", children: triggerHint }), (0, jsx_runtime_1.jsx)("span", { className: "line-clamp-1 text-body-sm font-semibold break-all text-grey-900", children: current.name })] })] })) : ((0, jsx_runtime_1.jsx)("span", { className: "line-clamp-1 text-body-sm font-medium break-words text-grey-500", children: placeholder })), (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "chevron-down", size: "sm", className: "ml-auto shrink-0 text-grey-500" })] }), (0, jsx_runtime_1.jsxs)("div", { id: panelId, role: "dialog", "aria-label": panelLabel, onKeyDown: (event) => {
276
+ if (event.key === "Escape") {
277
+ event.preventDefault();
278
+ close(true);
279
+ }
280
+ }, style: panelBox ? { left: panelBox.left, width: panelBox.width } : undefined, className: (0, cn_1.cn)("absolute top-[calc(100%+4px)] left-0 z-30 flex w-full min-w-0 flex-col rounded-xl border border-surface-border bg-surface shadow-soft-md", !open && "hidden"), children: [showFilter && status === "idle" && hasSource && ((0, jsx_runtime_1.jsxs)("div", { className: "border-b border-surface-border p-2", children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: filterId, className: "sr-only", children: filterLabel }), (0, jsx_runtime_1.jsx)("input", { ref: filterRef, id: filterId, type: "search", value: query, placeholder: filterPlaceholder, "aria-controls": listId, onChange: (event) => setQuery(event.target.value), onKeyDown: handleFilterKeyDown, className: (0, cn_1.cn)("w-full min-w-0 rounded-lg border border-surface-border-strong bg-surface px-3 py-2 text-body-sm text-grey-900 placeholder:text-grey-500", FOCUS_RING) })] })), (0, jsx_runtime_1.jsx)("div", { id: listId, className: "flex max-h-[320px] min-h-0 flex-col overflow-y-auto", children: renderBody() }), (0, jsx_runtime_1.jsx)("p", { role: "status", "aria-live": "polite", className: "sr-only", children: open && status === "idle" && hasSource ? resultCountLabel(matches.length) : "" }), footerActions.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-1 border-t border-surface-border p-1", children: footerActions.map((action) => {
281
+ const inner = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [action.icon && (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: action.icon, size: "sm", className: "shrink-0" }), (0, jsx_runtime_1.jsx)("span", { className: "break-words", children: action.label })] }));
282
+ const classes = (0, cn_1.cn)("flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-body-sm font-medium text-grey-700 hover:bg-surface-muted hover:text-grey-900", FOCUS_RING);
283
+ return action.href ? ((0, jsx_runtime_1.jsx)("a", { href: action.href, onClick: () => {
284
+ action.onClick?.();
285
+ close(false);
286
+ }, ...(action.external ? { target: "_blank", rel: "noreferrer" } : {}), className: classes, children: inner }, action.id)) : ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => {
287
+ action.onClick?.();
288
+ close(true);
289
+ }, className: classes, children: inner }, action.id));
290
+ }) }))] })] }));
291
+ }
@@ -0,0 +1,288 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ import { Icon } from "../components/Icon/index.js";
5
+ import { Spinner } from "../components/Spinner/index.js";
6
+ /* ─── Recipes ────────────────────────────────────────────────────────── */
7
+ const FOCUS_RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-action-primary-text";
8
+ /* ─── Logo ───────────────────────────────────────────────────────────── */
9
+ function WorkspaceMark({ item, size = "md", }) {
10
+ const box = size === "sm" ? "size-7 text-body-xs" : "size-9 text-body-sm";
11
+ if (item.logoSrc) {
12
+ return (_jsx("img", { src: item.logoSrc, alt: "", className: cn("shrink-0 rounded-lg border border-surface-border object-cover", box) }));
13
+ }
14
+ return (_jsx("span", { "aria-hidden": "true",
15
+ // The colour comes last: `box` carries a `text-body-*` size utility, and
16
+ // class merging treats it as the same group, so a colour written first is
17
+ // dropped and the initials fall back to the inherited foreground.
18
+ className: cn("inline-flex shrink-0 items-center justify-center rounded-lg bg-primary-50 font-semibold", box, "text-action-primary-text"), children: item.icon ? (_jsx(Icon, { name: item.icon, size: size === "sm" ? "sm" : "md" })) : ((item.initials ?? item.name.slice(0, 2).toUpperCase())) }));
19
+ }
20
+ /* ─── Component ──────────────────────────────────────────────────────── */
21
+ export function WorkspaceSwitcherBlock({ current, workspaces = [], onSelect, unavailableMessage = "Switching to this workspace is not available.", status = "idle", errorMessage = "Your workspaces could not be loaded.", onRetry, loadingMessage = "Loading workspaces", triggerLabel = "Switch workspace", triggerHint, placeholder = "Choose a workspace", panelLabel = "Workspaces", filterLabel = "Filter workspaces", filterPlaceholder = "Type to filter", filterThreshold = 6, emptyTitle = "You are not in any workspace yet", emptyDescription = "Create a workspace or ask an administrator for an invitation.", noMatchesTitle = "No workspace matches that filter", noMatchesDescription = "Check the spelling, or clear the filter to see every workspace.", footerActions = [], resultCountLabel = (count) => (count === 1 ? "1 workspace matches" : `${count} workspaces match`), defaultOpen = false, open: controlledOpen, onOpenChange, className, }) {
22
+ const uid = useId();
23
+ const panelId = `${uid}-panel`;
24
+ const listId = `${uid}-list`;
25
+ const filterId = `${uid}-filter`;
26
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
27
+ const open = controlledOpen ?? uncontrolledOpen;
28
+ const [query, setQuery] = useState("");
29
+ /** Roving tabindex: exactly one option is in the tab order at a time. */
30
+ const [activeIndex, setActiveIndex] = useState(0);
31
+ const rootRef = useRef(null);
32
+ const triggerRef = useRef(null);
33
+ /**
34
+ * The popover is wider than its trigger, so a trigger sitting near the right
35
+ * edge would push the popover past it and widen the document. The offset is
36
+ * measured from the trigger when the popover opens: start-aligned when it
37
+ * fits, end-aligned when it does not, and pinned to the viewport when neither
38
+ * side fits. Nothing is positioned until it is measured.
39
+ */
40
+ const [panelBox, setPanelBox] = useState(null);
41
+ const filterRef = useRef(null);
42
+ const listRef = useRef(null);
43
+ const setOpen = useCallback((next) => {
44
+ if (controlledOpen === undefined)
45
+ setUncontrolledOpen(next);
46
+ onOpenChange?.(next);
47
+ }, [controlledOpen, onOpenChange]);
48
+ const close = useCallback((restoreFocus) => {
49
+ setOpen(false);
50
+ if (restoreFocus)
51
+ triggerRef.current?.focus();
52
+ }, [setOpen]);
53
+ /* Closing when the pointer lands outside keeps the popover from shadowing the
54
+ page; it is deliberately not a modal, so focus is never trapped here. */
55
+ useEffect(() => {
56
+ if (!open)
57
+ return;
58
+ function handlePointerDown(event) {
59
+ if (!rootRef.current)
60
+ return;
61
+ if (!rootRef.current.contains(event.target))
62
+ setOpen(false);
63
+ }
64
+ document.addEventListener("mousedown", handlePointerDown);
65
+ return () => document.removeEventListener("mousedown", handlePointerDown);
66
+ }, [open, setOpen]);
67
+ useEffect(() => {
68
+ if (!open) {
69
+ setPanelBox(null);
70
+ return;
71
+ }
72
+ function place() {
73
+ const rect = triggerRef.current?.getBoundingClientRect();
74
+ if (!rect)
75
+ return;
76
+ const viewport = document.documentElement.clientWidth;
77
+ const width = Math.min(320, Math.max(rect.width, viewport - 24));
78
+ let left = 0;
79
+ if (rect.left + width > viewport - 12) {
80
+ left = rect.width - width;
81
+ if (rect.left + left < 12)
82
+ left = 12 - rect.left;
83
+ }
84
+ setPanelBox({ left, width });
85
+ }
86
+ place();
87
+ window.addEventListener("resize", place);
88
+ // A window resize is not the only thing that moves the trigger: a root text
89
+ // size change, a sidebar collapsing, or a container query flipping all do it
90
+ // without firing `resize`.
91
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => place());
92
+ if (observer && triggerRef.current)
93
+ observer.observe(triggerRef.current);
94
+ if (observer)
95
+ observer.observe(document.documentElement);
96
+ return () => {
97
+ window.removeEventListener("resize", place);
98
+ observer?.disconnect();
99
+ };
100
+ }, [open]);
101
+ /* The filter is where a keyboard user expects to land, and it is where typing
102
+ already works, so it takes focus as the popover opens. */
103
+ useEffect(() => {
104
+ if (!open)
105
+ return;
106
+ const target = filterRef.current ?? listRef.current?.querySelector("[data-option]");
107
+ target?.focus();
108
+ }, [open]);
109
+ useEffect(() => {
110
+ if (!open)
111
+ setQuery("");
112
+ }, [open]);
113
+ useEffect(() => {
114
+ setActiveIndex(0);
115
+ }, [query, open]);
116
+ const matches = useMemo(() => {
117
+ const needle = query.trim().toLocaleLowerCase();
118
+ if (!needle)
119
+ return workspaces;
120
+ return workspaces.filter((item) => `${item.name} ${item.detail ?? ""}`.toLocaleLowerCase().includes(needle));
121
+ }, [query, workspaces]);
122
+ const showFilter = workspaces.length >= filterThreshold;
123
+ const hasSource = workspaces.length > 0;
124
+ /* ── Roving focus across the options ─────────────────────────────── */
125
+ function optionNodes() {
126
+ if (!listRef.current)
127
+ return [];
128
+ return Array.from(listRef.current.querySelectorAll("[data-option]"));
129
+ }
130
+ function focusOptionAt(index) {
131
+ const nodes = optionNodes();
132
+ if (nodes.length === 0)
133
+ return;
134
+ const wrapped = ((index % nodes.length) + nodes.length) % nodes.length;
135
+ setActiveIndex(wrapped);
136
+ nodes[wrapped]?.focus();
137
+ }
138
+ function handleFilterKeyDown(event) {
139
+ if (event.key === "ArrowDown") {
140
+ event.preventDefault();
141
+ focusOptionAt(0);
142
+ }
143
+ else if (event.key === "ArrowUp") {
144
+ event.preventDefault();
145
+ focusOptionAt(-1);
146
+ }
147
+ else if (event.key === "Escape") {
148
+ event.preventDefault();
149
+ close(true);
150
+ }
151
+ }
152
+ function handleOptionKeyDown(event, index) {
153
+ switch (event.key) {
154
+ case "ArrowDown":
155
+ event.preventDefault();
156
+ focusOptionAt(index + 1);
157
+ break;
158
+ case "ArrowUp":
159
+ event.preventDefault();
160
+ focusOptionAt(index - 1);
161
+ break;
162
+ case "Home":
163
+ event.preventDefault();
164
+ focusOptionAt(0);
165
+ break;
166
+ case "End":
167
+ event.preventDefault();
168
+ focusOptionAt(-1);
169
+ break;
170
+ case "Escape":
171
+ event.preventDefault();
172
+ close(true);
173
+ break;
174
+ default:
175
+ break;
176
+ }
177
+ }
178
+ function handleTriggerKeyDown(event) {
179
+ if (event.key === "ArrowDown" && !open) {
180
+ event.preventDefault();
181
+ setOpen(true);
182
+ }
183
+ }
184
+ function choose(item) {
185
+ if ("unavailableReason" in item && item.unavailableReason)
186
+ return;
187
+ if (!onSelect)
188
+ return;
189
+ onSelect(item.id);
190
+ close(true);
191
+ }
192
+ /* ── Option ──────────────────────────────────────────────────────── */
193
+ function renderOption(item, index) {
194
+ const unavailable = ("unavailableReason" in item ? item.unavailableReason : undefined) ||
195
+ (!item.href && !onSelect ? unavailableMessage : undefined);
196
+ const isCurrent = current?.id === item.id;
197
+ const reasonId = `${uid}-${item.id}-reason`;
198
+ const body = (_jsxs(_Fragment, { children: [_jsx(WorkspaceMark, { item: item, size: "sm" }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5 text-left", children: [_jsx("span", { className: "text-body-sm font-medium break-words", children: item.name }), item.detail && (_jsx("span", { className: "text-body-xs break-words text-grey-500", children: item.detail })), unavailable && (_jsx("span", { id: reasonId, className: "text-body-xs break-words text-grey-500", children: unavailable }))] }), isCurrent && (_jsxs("span", { className: "ml-auto inline-flex shrink-0 items-center gap-1 text-body-xs font-semibold text-action-primary-text", children: [_jsx(Icon, { name: "check", size: "sm" }), _jsx("span", { children: "Current" })] }))] }));
199
+ /**
200
+ * The current entry is marked by the "Current" chip, not by a tint. An
201
+ * earlier draft tinted the row `bg-primary-50`; the muted detail line then
202
+ * measured 4.28:1 on it, below AA, which is the same mistake the Batch A
203
+ * pricing cards made with `opacity-90`. `surface-muted` is a ground the
204
+ * muted foreground role is already paired with in both modes.
205
+ */
206
+ const classes = cn("flex min-h-11 w-full min-w-0 items-start gap-3 rounded-lg px-2 py-2 transition-colors", FOCUS_RING, unavailable
207
+ ? "cursor-not-allowed text-grey-500"
208
+ : "cursor-pointer text-grey-900 hover:bg-surface-muted", isCurrent && !unavailable && "bg-surface-muted");
209
+ const shared = {
210
+ "data-option": "true",
211
+ role: "option",
212
+ tabIndex: index === activeIndex ? 0 : -1,
213
+ "aria-selected": isCurrent,
214
+ onKeyDown: (event) => handleOptionKeyDown(event, index),
215
+ onFocus: () => setActiveIndex(index),
216
+ className: classes,
217
+ };
218
+ if (unavailable) {
219
+ return (_jsx("div", { ...shared, "aria-disabled": "true", "aria-describedby": reasonId, children: body }, item.id));
220
+ }
221
+ if (item.href) {
222
+ // A workspace that is its own address stays a real anchor; the listbox
223
+ // role does not remove the href, so middle-click still opens a new tab.
224
+ return (_jsx("a", { ...shared, href: item.href, onClick: () => {
225
+ onSelect?.(item.id);
226
+ close(false);
227
+ }, children: body }, item.id));
228
+ }
229
+ return (_jsx("div", { ...shared, onClick: () => choose(item), onKeyDown: (event) => {
230
+ if (event.key === "Enter" || event.key === " ") {
231
+ event.preventDefault();
232
+ choose(item);
233
+ return;
234
+ }
235
+ handleOptionKeyDown(event, index);
236
+ }, children: body }, item.id));
237
+ }
238
+ /* ── Panel body ──────────────────────────────────────────────────── */
239
+ function renderBody() {
240
+ if (status === "loading") {
241
+ return (_jsxs("p", { role: "status", className: "flex items-center gap-2 px-2 py-6 text-body-sm text-grey-500", children: [_jsx(Spinner, { size: "sm" }), _jsx("span", { children: loadingMessage })] }));
242
+ }
243
+ if (status === "error") {
244
+ return (_jsxs("div", { role: "alert", className: "m-1 flex flex-col gap-2 rounded-lg border border-error-200 bg-error-50 px-3 py-3 text-body-sm text-error-700", children: [_jsx("p", { className: "break-words", children: errorMessage }), onRetry && (_jsxs("button", { type: "button", onClick: onRetry, className: cn("inline-flex min-h-9 w-fit cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-current px-3 py-1.5 font-semibold", FOCUS_RING), children: [_jsx(Icon, { name: "refresh", size: "sm" }), "Try again"] }))] }));
245
+ }
246
+ if (!hasSource) {
247
+ return (_jsxs("div", { className: "flex flex-col gap-1 px-2 py-6", children: [_jsx("p", { className: "text-body-sm font-semibold break-words text-grey-900", children: emptyTitle }), _jsx("p", { className: "text-body-sm break-words text-grey-500", children: emptyDescription })] }));
248
+ }
249
+ if (matches.length === 0) {
250
+ return (_jsxs("div", { className: "flex flex-col gap-1 px-2 py-6", children: [_jsx("p", { className: "text-body-sm font-semibold break-words text-grey-900", children: noMatchesTitle }), _jsx("p", { className: "text-body-sm break-words text-grey-500", children: noMatchesDescription })] }));
251
+ }
252
+ return (_jsx("div", { ref: listRef, role: "listbox", "aria-label": panelLabel, className: "flex flex-col gap-0.5 p-1", children: matches.map((item, index) => renderOption(item, index)) }));
253
+ }
254
+ /* ── Trigger ─────────────────────────────────────────────────────── */
255
+ /**
256
+ * The spoken name repeats every visible word in the order it is drawn, so a
257
+ * voice-control user can say what they can see (WCAG 2.5.3), and then adds
258
+ * the purpose the icon alone would not carry.
259
+ */
260
+ const triggerName = current
261
+ ? `${triggerHint ? `${triggerHint}, ` : ""}${current.name}. ${triggerLabel}`
262
+ : `${placeholder}. ${triggerLabel}`;
263
+ return (_jsxs("div", { ref: rootRef,
264
+ /**
265
+ * Deliberately NOT an `@container`. `container-type: inline-size` makes an
266
+ * element's inline size independent of its own contents, so this root —
267
+ * a shrinkable flex item inside the shell's top bar — resolved to 0px at
268
+ * every width and the switcher disappeared. The block has no internal
269
+ * container query to justify the cost: the popover is placed from a
270
+ * measurement instead.
271
+ */
272
+ className: cn("relative w-full min-w-0", className), children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, "aria-label": triggerName, onClick: () => setOpen(!open), onKeyDown: handleTriggerKeyDown, className: cn("flex min-h-11 w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border border-surface-border bg-surface px-2 py-1.5 text-left text-grey-700 transition-colors hover:bg-surface-muted", FOCUS_RING), children: [current ? (_jsxs(_Fragment, { children: [_jsx(WorkspaceMark, { item: current, size: "sm" }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [triggerHint && _jsx("span", { className: "text-body-xs text-grey-500", children: triggerHint }), _jsx("span", { className: "line-clamp-1 text-body-sm font-semibold break-all text-grey-900", children: current.name })] })] })) : (_jsx("span", { className: "line-clamp-1 text-body-sm font-medium break-words text-grey-500", children: placeholder })), _jsx(Icon, { name: "chevron-down", size: "sm", className: "ml-auto shrink-0 text-grey-500" })] }), _jsxs("div", { id: panelId, role: "dialog", "aria-label": panelLabel, onKeyDown: (event) => {
273
+ if (event.key === "Escape") {
274
+ event.preventDefault();
275
+ close(true);
276
+ }
277
+ }, style: panelBox ? { left: panelBox.left, width: panelBox.width } : undefined, className: cn("absolute top-[calc(100%+4px)] left-0 z-30 flex w-full min-w-0 flex-col rounded-xl border border-surface-border bg-surface shadow-soft-md", !open && "hidden"), children: [showFilter && status === "idle" && hasSource && (_jsxs("div", { className: "border-b border-surface-border p-2", children: [_jsx("label", { htmlFor: filterId, className: "sr-only", children: filterLabel }), _jsx("input", { ref: filterRef, id: filterId, type: "search", value: query, placeholder: filterPlaceholder, "aria-controls": listId, onChange: (event) => setQuery(event.target.value), onKeyDown: handleFilterKeyDown, className: cn("w-full min-w-0 rounded-lg border border-surface-border-strong bg-surface px-3 py-2 text-body-sm text-grey-900 placeholder:text-grey-500", FOCUS_RING) })] })), _jsx("div", { id: listId, className: "flex max-h-[320px] min-h-0 flex-col overflow-y-auto", children: renderBody() }), _jsx("p", { role: "status", "aria-live": "polite", className: "sr-only", children: open && status === "idle" && hasSource ? resultCountLabel(matches.length) : "" }), footerActions.length > 0 && (_jsx("div", { className: "flex flex-col gap-1 border-t border-surface-border p-1", children: footerActions.map((action) => {
278
+ const inner = (_jsxs(_Fragment, { children: [action.icon && _jsx(Icon, { name: action.icon, size: "sm", className: "shrink-0" }), _jsx("span", { className: "break-words", children: action.label })] }));
279
+ const classes = cn("flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-body-sm font-medium text-grey-700 hover:bg-surface-muted hover:text-grey-900", FOCUS_RING);
280
+ return action.href ? (_jsx("a", { href: action.href, onClick: () => {
281
+ action.onClick?.();
282
+ close(false);
283
+ }, ...(action.external ? { target: "_blank", rel: "noreferrer" } : {}), className: classes, children: inner }, action.id)) : (_jsx("button", { type: "button", onClick: () => {
284
+ action.onClick?.();
285
+ close(true);
286
+ }, className: classes, children: inner }, action.id));
287
+ }) }))] })] }));
288
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.commerceAction = exports.commerceControl = void 0;
4
+ exports.formatCommerceMoney = formatCommerceMoney;
5
+ exports.CommerceHeading = CommerceHeading;
6
+ exports.CommerceFrame = CommerceFrame;
7
+ exports.CommercePhoto = CommercePhoto;
8
+ exports.CommerceQuantity = CommerceQuantity;
9
+ exports.CommerceTotals = CommerceTotals;
10
+ const jsx_runtime_1 = require("react/jsx-runtime");
11
+ const react_1 = require("react");
12
+ const cn_1 = require("../utils/cn.cjs");
13
+ exports.commerceControl = "cursor-pointer min-h-11 rounded-lg border border-surface-border-strong bg-surface px-3 py-2 text-base text-grey-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-action-primary motion-safe:transition-colors motion-safe:duration-150 hover:border-grey-500 disabled:cursor-not-allowed disabled:opacity-50";
14
+ exports.commerceAction = "inline-flex min-h-11 items-center justify-center rounded-lg bg-action-primary px-5 py-2.5 text-center text-sm font-semibold text-white hover:bg-action-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-action-primary disabled:cursor-not-allowed disabled:opacity-50";
15
+ function formatCommerceMoney(amount, currency = "GBP", locale = "en-GB") {
16
+ const format = new Intl.NumberFormat(locale, { style: "currency", currency });
17
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
18
+ return format.format(amount / 10 ** digits);
19
+ }
20
+ function CommerceHeading({ title, description, headingLevel: Heading = "h2", id, }) {
21
+ return ((0, jsx_runtime_1.jsxs)("header", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)(Heading, { id: id, className: "max-w-[20ch] text-[clamp(1.75rem,4cqi,2.75rem)] leading-[1.12] font-semibold tracking-[-0.035em]", children: title }), description && ((0, jsx_runtime_1.jsx)("p", { className: "max-w-[56ch] text-base leading-relaxed text-grey-600", children: description }))] }));
22
+ }
23
+ function CommerceFrame({ title, description, headingLevel, className, children, busy, }) {
24
+ const id = (0, react_1.useId)();
25
+ return ((0, jsx_runtime_1.jsxs)("section", { "aria-labelledby": id, "aria-busy": busy || undefined, className: (0, cn_1.cn)("@container min-w-0 w-full bg-surface text-grey-900 [overflow-wrap:anywhere]", className), children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-8 @min-[760px]:mb-10", children: (0, jsx_runtime_1.jsx)(CommerceHeading, { id: id, title: title, description: description, headingLevel: headingLevel }) }), children] }));
26
+ }
27
+ function CommercePhoto({ image, className, priority = false, }) {
28
+ return image ? ((0, jsx_runtime_1.jsx)("img", { src: image.src, alt: image.alt, loading: priority ? "eager" : "lazy", width: 640, height: 640, className: (0, cn_1.cn)("aspect-square w-full rounded-lg bg-surface-muted object-cover", className) })) : ((0, jsx_runtime_1.jsx)("div", { role: "img", "aria-label": "Product image unavailable", className: (0, cn_1.cn)("flex aspect-square items-center justify-center rounded-xl bg-surface-muted p-4 text-center text-sm text-grey-600", className), children: "Image unavailable" }));
29
+ }
30
+ function CommerceQuantity({ name, quantity, max, disabled, onChange, }) {
31
+ return ((0, jsx_runtime_1.jsxs)("div", { role: "group", "aria-label": `Quantity for ${name}`, className: "inline-flex shrink-0 items-center rounded-lg border border-surface-border-strong bg-surface", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": `Decrease quantity for ${name}`, disabled: disabled || quantity <= 1, className: (0, cn_1.cn)(exports.commerceControl, "w-11 border-0 bg-transparent px-0 hover:bg-surface-muted"), onClick: () => onChange(quantity - 1), children: "\u2212" }), (0, jsx_runtime_1.jsx)("output", { "aria-label": `Quantity for ${name}`, className: "min-w-7 px-1 text-center tabular-nums", children: quantity }), (0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": `Increase quantity for ${name}`, disabled: disabled || quantity >= (max ?? 99), className: (0, cn_1.cn)(exports.commerceControl, "w-11 border-0 bg-transparent px-0 hover:bg-surface-muted"), onClick: () => onChange(quantity + 1), children: "+" })] }));
32
+ }
33
+ function CommerceTotals({ items, shipping = 0, tax = 0, discount = 0, currency, locale, }) {
34
+ const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
35
+ const total = Math.max(0, subtotal + shipping + tax - discount);
36
+ const rows = [
37
+ ["Subtotal", subtotal],
38
+ ["Delivery", shipping],
39
+ ["Tax", tax],
40
+ ...(discount ? [["Discount", -discount]] : []),
41
+ ["Total", total],
42
+ ];
43
+ return ((0, jsx_runtime_1.jsx)("dl", { className: "space-y-3 text-sm", children: rows.map(([label, amount]) => ((0, jsx_runtime_1.jsxs)("div", { className: (0, cn_1.cn)("flex items-baseline justify-between gap-4", label === "Total" && "border-t border-surface-border-strong pt-5 text-xl font-semibold"), children: [(0, jsx_runtime_1.jsx)("dt", { children: label }), (0, jsx_runtime_1.jsx)("dd", { className: "text-right tabular-nums", children: formatCommerceMoney(amount, currency, locale) })] }, label))) }));
44
+ }