@heyramzi/ui 6.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/COMPONENTS.md +32 -40
  2. package/README.md +10 -4
  3. package/package.json +31 -29
  4. package/scripts/sync-shadcn.mjs +29 -16
  5. package/scripts/sync-transitions.mjs +11 -11
  6. package/src/ai/ai-button/AiButton.svelte +86 -0
  7. package/src/ai/ai-button/index.ts +4 -0
  8. package/src/ai/confirmation/Confirmation.svelte +8 -8
  9. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  10. package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
  11. package/src/ai/generation-progress/index.ts +7 -0
  12. package/src/ai/index.ts +16 -24
  13. package/src/ai/prompt-input/PromptInput.svelte +19 -1
  14. package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
  15. package/src/ai/reasoning/Reasoning.svelte +11 -8
  16. package/src/ambient.d.ts +4 -0
  17. package/src/animations/README.md +17 -17
  18. package/src/animations/index.css +3 -14
  19. package/src/animations/upstream-coverage.test.ts +1 -1
  20. package/src/components/copy/clipboard.test.ts +206 -0
  21. package/src/components/copy/clipboard.ts +49 -0
  22. package/src/components/copy/copied.svelte.ts +39 -0
  23. package/src/components/copy/copy-button.svelte +175 -0
  24. package/src/components/copy/copy-button.test.ts +110 -0
  25. package/src/components/copy/index.ts +3 -0
  26. package/src/components/data-table/data-table.test.ts +134 -0
  27. package/src/components/data-table/data-table.ts +77 -0
  28. package/src/components/data-table/index.ts +20 -0
  29. package/src/components/data-table/infinite-scroll.svelte +69 -0
  30. package/src/components/data-table/pagination.svelte +62 -0
  31. package/src/components/data-table/row-arrow.svelte +28 -0
  32. package/src/components/data-table/row-context-menu.svelte +106 -0
  33. package/src/components/data-table/row-context-menu.test.ts +18 -0
  34. package/src/components/data-table/sortable-head.svelte +56 -0
  35. package/src/components/data-table/table-skeleton.svelte +83 -0
  36. package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
  37. package/src/components/hold-to-confirm/index.ts +1 -0
  38. package/src/components/icon/icon.svelte +23 -0
  39. package/src/components/icon/index.ts +1 -0
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-flag/language-flag.svelte +60 -0
  42. package/src/components/list-page/detail-empty.svelte +50 -0
  43. package/src/components/list-page/detail-header.svelte +107 -0
  44. package/src/components/list-page/detail-panel.svelte +90 -0
  45. package/src/components/list-page/detail-row.svelte +36 -0
  46. package/src/components/list-page/detail-stack.svelte +33 -0
  47. package/src/components/list-page/empty-state.svelte +91 -0
  48. package/src/components/list-page/filter-item.svelte +49 -0
  49. package/src/components/list-page/filter-popover.svelte +88 -0
  50. package/src/components/list-page/filter-section.ts +12 -0
  51. package/src/components/list-page/grouped-list.svelte +116 -0
  52. package/src/components/list-page/index.ts +28 -0
  53. package/src/components/list-page/list-page-shell.svelte +195 -0
  54. package/src/components/list-page/list-table.ts +19 -0
  55. package/src/components/list-page/master-detail.svelte +206 -0
  56. package/src/components/list-page/master-detail.test.ts +37 -0
  57. package/src/components/list-page/notice-bar.svelte +68 -0
  58. package/src/components/list-page/prev-next.svelte +104 -0
  59. package/src/components/list-page/queue-rail.ts +25 -0
  60. package/src/components/list-page/rail-body.svelte +29 -0
  61. package/src/components/list-page/rail-empty.svelte +51 -0
  62. package/src/components/list-page/rail-head.svelte +49 -0
  63. package/src/components/list-page/rail-row.svelte +163 -0
  64. package/src/components/list-page/section-host.ts +24 -0
  65. package/src/components/list-page/section-label.svelte +39 -0
  66. package/src/components/list-page/step.test.ts +21 -0
  67. package/src/components/list-page/step.ts +10 -0
  68. package/src/components/list-page/toolbar-search.svelte +63 -0
  69. package/src/components/list-page/toolbar-trigger.ts +23 -0
  70. package/src/components/list-page/types.ts +1 -0
  71. package/src/components/locale-switch/index.ts +1 -0
  72. package/src/components/locale-switch/locale-switch.svelte +59 -0
  73. package/src/components/mark/index.ts +1 -0
  74. package/src/components/mark/mark.svelte +70 -0
  75. package/src/components/open-in/index.ts +6 -0
  76. package/src/components/open-in/open-in.svelte +106 -0
  77. package/src/components/picker/index.ts +4 -0
  78. package/src/components/picker/mark-picker.svelte +84 -0
  79. package/src/components/picker/picker.svelte +143 -0
  80. package/src/components/picker/trigger.ts +42 -0
  81. package/src/components/picker/types.ts +26 -0
  82. package/src/components/segmented/animated-tabs.svelte +175 -0
  83. package/src/components/segmented/collapse.test.ts +49 -0
  84. package/src/components/segmented/collapse.ts +39 -0
  85. package/src/components/segmented/index.ts +41 -0
  86. package/src/components/segmented/section-runs.svelte +79 -0
  87. package/src/components/segmented/segment.svelte +106 -0
  88. package/src/components/segmented/segmented-filter.svelte +125 -0
  89. package/src/components/segmented/segmented-nav.svelte +82 -0
  90. package/src/components/segmented/segmented-overflow.svelte +85 -0
  91. package/src/components/segmented/segmented-track.svelte +181 -0
  92. package/src/components/segmented/segmented.test.ts +20 -0
  93. package/src/components/segmented/segmented.ts +80 -0
  94. package/src/components/star-rating-input/index.ts +9 -0
  95. package/src/components/star-rating-input/star-rating-input.svelte +256 -0
  96. package/src/components/svg/index.ts +1 -0
  97. package/src/components/svg/svg.svelte +9 -0
  98. package/src/effects/border-beam/BorderBeam.svelte +1 -1
  99. package/src/effects/image-comparison/ImageComparison.css +42 -8
  100. package/src/effects/image-comparison/ImageComparison.svelte +138 -21
  101. package/src/effects/image-comparison/compare.test.ts +17 -0
  102. package/src/effects/image-comparison/compare.ts +5 -0
  103. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
  104. package/src/effects/magic-card/MagicCard.css +47 -0
  105. package/src/effects/magic-card/MagicCard.svelte +57 -0
  106. package/src/effects/marquee/Marquee.css +27 -1
  107. package/src/effects/marquee/Marquee.svelte +67 -3
  108. package/src/effects/reveal/index.ts +2 -1
  109. package/src/effects/reveal/observe.test.ts +60 -0
  110. package/src/effects/reveal/observe.ts +33 -1
  111. package/src/effects/reveal/types.ts +7 -0
  112. package/src/effects/theme-toggle/ThemeToggle.css +199 -6
  113. package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
  114. package/src/gallery.test.ts +52 -0
  115. package/src/index.ts +5 -1
  116. package/src/lib/panel.ts +7 -1
  117. package/src/lib/sliding-pill.ts +68 -0
  118. package/src/lib/theme-transition.ts +41 -13
  119. package/src/lib/tone.ts +48 -0
  120. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  121. package/src/shadcn/alert/alert.svelte +3 -1
  122. package/src/shadcn/badge/badge.svelte +8 -7
  123. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  124. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  125. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  126. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  127. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  128. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  129. package/src/shadcn/calendar/calendar.svelte +6 -0
  130. package/src/shadcn/code-block/code-block-actions.svelte +23 -0
  131. package/src/shadcn/code-block/code-block-content.svelte +55 -0
  132. package/src/shadcn/code-block/code-block-copy-button.svelte +10 -0
  133. package/src/shadcn/code-block/code-block-footer.svelte +20 -0
  134. package/src/shadcn/code-block/code-block-header.svelte +23 -0
  135. package/src/shadcn/code-block/code-block-line.svelte +71 -0
  136. package/src/shadcn/code-block/code-block-title.svelte +23 -0
  137. package/src/shadcn/code-block/code-block.svelte +47 -0
  138. package/src/shadcn/code-block/index.ts +42 -0
  139. package/src/shadcn/command/index.ts +3 -0
  140. package/src/shadcn/field/index.ts +3 -0
  141. package/src/shadcn/field/simple-field.svelte +71 -0
  142. package/src/shadcn/field/simple-field.test.ts +47 -0
  143. package/src/shadcn/hooks/highlighter.ts +132 -0
  144. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
  145. package/src/shadcn/kbd/index.ts +10 -0
  146. package/src/shadcn/kbd/kbd-group.svelte +20 -0
  147. package/src/shadcn/kbd/kbd.svelte +23 -0
  148. package/src/shadcn/message-scroller/index.ts +28 -0
  149. package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
  150. package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
  151. package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
  152. package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
  153. package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
  154. package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
  155. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  156. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  157. package/src/shadcn/sheet/sheet.css +48 -0
  158. package/src/shadcn/sonner/sonner.svelte +7 -10
  159. package/src/shadcn/tabs/tabs-list.svelte +13 -2
  160. package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
  161. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  162. package/src/shadcn/trace/index.ts +29 -0
  163. package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
  164. package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
  165. package/src/shadcn/trace/trace-collapsible.svelte +20 -0
  166. package/src/shadcn/trace/trace-description.svelte +20 -0
  167. package/src/shadcn/trace/trace-icon.svelte +24 -0
  168. package/src/shadcn/trace/trace-line.svelte +31 -0
  169. package/src/shadcn/trace/trace-title.svelte +20 -0
  170. package/src/shadcn/trace/trace.svelte +23 -0
  171. package/src/ai/composer/Composer.css +0 -108
  172. package/src/ai/composer/Composer.svelte +0 -111
  173. package/src/ai/composer/index.ts +0 -10
  174. package/src/ai/conversation/Conversation.svelte +0 -28
  175. package/src/ai/conversation/ConversationContent.svelte +0 -35
  176. package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
  177. package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
  178. package/src/ai/conversation/index.ts +0 -33
  179. package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
  180. package/src/animations/accordion.css +0 -58
  181. package/src/animations/icon-swap.css +0 -43
  182. package/src/animations/notification-badge.css +0 -81
  183. package/src/animations/panel.css +0 -86
  184. package/src/animations/reasoning-stream.css +0 -49
  185. package/src/animations/skeleton-reveal.css +0 -97
  186. package/src/animations/streaming-text.css +0 -31
  187. package/src/animations/tabs-sliding.css +0 -83
  188. package/src/animations/texts-reveal.css +0 -70
  189. package/src/animations/tooltip.css +0 -72
  190. package/src/animations/view-transition-reveal.ts +0 -74
  191. package/src/charts/animated-value.css +0 -7
  192. package/src/charts/animated-value.svelte +0 -110
  193. package/src/charts/area-chart.css +0 -29
  194. package/src/charts/area-chart.svelte +0 -195
  195. package/src/charts/bar-chart.css +0 -119
  196. package/src/charts/bar-chart.svelte +0 -269
  197. package/src/charts/charts.css +0 -201
  198. package/src/charts/crosshair.svelte +0 -35
  199. package/src/charts/donut-chart.css +0 -56
  200. package/src/charts/donut-chart.svelte +0 -158
  201. package/src/charts/edge-fade.svelte +0 -43
  202. package/src/charts/edge-fade.ts +0 -16
  203. package/src/charts/figure.css +0 -100
  204. package/src/charts/figure.svelte +0 -122
  205. package/src/charts/gauge-chart.css +0 -94
  206. package/src/charts/gauge-chart.svelte +0 -303
  207. package/src/charts/hover-band.test.ts +0 -30
  208. package/src/charts/hover-band.ts +0 -21
  209. package/src/charts/hover-focus.svelte +0 -85
  210. package/src/charts/index.ts +0 -30
  211. package/src/charts/legend.css +0 -131
  212. package/src/charts/legend.svelte +0 -112
  213. package/src/charts/line-chart.css +0 -26
  214. package/src/charts/line-chart.svelte +0 -256
  215. package/src/charts/motion.ts +0 -19
  216. package/src/charts/number-chart.css +0 -168
  217. package/src/charts/number-chart.svelte +0 -177
  218. package/src/charts/primitives/chart-container.svelte +0 -57
  219. package/src/charts/primitives/chart-style.svelte +0 -36
  220. package/src/charts/primitives/chart-tooltip.svelte +0 -161
  221. package/src/charts/primitives/chart-utils.ts +0 -54
  222. package/src/charts/primitives/index.ts +0 -11
  223. package/src/charts/reveal.svelte +0 -61
  224. package/src/charts/ring-chart.css +0 -96
  225. package/src/charts/ring-chart.svelte +0 -158
  226. package/src/charts/sparkline.css +0 -45
  227. package/src/charts/sparkline.svelte +0 -140
  228. package/src/charts/stat-card.css +0 -103
  229. package/src/charts/stat-card.svelte +0 -267
  230. package/src/charts/time-series-chart.css +0 -28
  231. package/src/charts/time-series-chart.svelte +0 -179
  232. package/src/charts/trend-pill.css +0 -38
  233. package/src/charts/trend-pill.svelte +0 -48
  234. package/src/lib/hooks/is-mobile.svelte.ts +0 -11
  235. package/src/lib/utils.ts +0 -45
  236. package/src/showcase/AiSection.svelte +0 -157
  237. package/src/showcase/AnimationsSection.svelte +0 -1658
  238. package/src/showcase/ChartsSection.svelte +0 -458
  239. package/src/showcase/EffectsSection.svelte +0 -152
  240. package/src/showcase/ShadcnSection.svelte +0 -27
  241. package/src/showcase/ShowcaseImport.svelte +0 -126
  242. package/src/showcase/ShowcaseList.svelte +0 -132
  243. package/src/showcase/ShowcaseNav.svelte +0 -247
  244. package/src/showcase/ShowcaseSection.svelte +0 -112
  245. package/src/showcase/ShowcaseShell.svelte +0 -55
  246. package/src/showcase/ShowcaseStage.svelte +0 -342
  247. package/src/showcase/ShowcaseVariant.svelte +0 -109
  248. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  249. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  250. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  251. package/src/showcase/check.svg +0 -1
  252. package/src/showcase/chevron-arms.svg +0 -2
  253. package/src/showcase/chevron-down.svg +0 -1
  254. package/src/showcase/copy.svg +0 -2
  255. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  256. package/src/showcase/demos/ai/conversation.svelte +0 -59
  257. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  258. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  259. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  260. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  261. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  262. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  263. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  264. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  265. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  266. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  267. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  268. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  269. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  270. package/src/showcase/demos/effects/in-view.svelte +0 -41
  271. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  272. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  273. package/src/showcase/demos/effects/marquee.svelte +0 -33
  274. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  275. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  276. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  277. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  278. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  279. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  280. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  281. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  282. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  283. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  284. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  285. package/src/showcase/demos/shadcn/button.svelte +0 -11
  286. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  287. package/src/showcase/demos/shadcn/card.svelte +0 -39
  288. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  289. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  290. package/src/showcase/demos/shadcn/command.svelte +0 -48
  291. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  292. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  293. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  294. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  295. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  296. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  297. package/src/showcase/demos/shadcn/field.svelte +0 -115
  298. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  299. package/src/showcase/demos/shadcn/index.ts +0 -358
  300. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  301. package/src/showcase/demos/shadcn/input.svelte +0 -5
  302. package/src/showcase/demos/shadcn/label.svelte +0 -11
  303. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  304. package/src/showcase/demos/shadcn/message.svelte +0 -75
  305. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  306. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  307. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  308. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  309. package/src/showcase/demos/shadcn/select.svelte +0 -29
  310. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  311. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  312. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  313. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  314. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  315. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  316. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  317. package/src/showcase/demos/shadcn/table.svelte +0 -76
  318. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  319. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  320. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  321. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  322. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  323. package/src/showcase/heart.svg +0 -1
  324. package/src/showcase/index.ts +0 -38
  325. package/src/showcase/kit.test.ts +0 -72
  326. package/src/showcase/kit.ts +0 -17
  327. package/src/showcase/plus.svg +0 -1
  328. package/src/showcase/showcase-shell.css +0 -91
  329. package/src/showcase/stacked-section.css +0 -15
  330. package/src/showcase/success-check.svg +0 -1
  331. package/src/showcase/types.ts +0 -29
  332. package/src/svg-raw.d.ts +0 -7
@@ -0,0 +1,84 @@
1
+ <script lang="ts" generics="V extends string">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { CaretDown } from "phosphor-svelte";
6
+ import { Mark } from "../mark/index.js";
7
+ import { cn } from "@heyramzi/utils";
8
+ import Picker from "./picker.svelte";
9
+ import { pickerTriggerClass } from "./trigger.js";
10
+ import type { PickerOption } from "./types.js";
11
+
12
+ // =========================
13
+ // TYPES
14
+ // =========================
15
+
16
+ /**
17
+ * The picker for named things carrying a mark: a kit, a channel, a client. It
18
+ * wraps `Picker` because the kit strip and the channel row wrote the same two
19
+ * snippets.
20
+ */
21
+ type Props = {
22
+ options: PickerOption<V>[];
23
+ value: V | null;
24
+ onSelect: (value: V) => void;
25
+ ariaLabel: string;
26
+ /** Said by the trigger when nothing in the list is the current value. */
27
+ placeholder?: string;
28
+ /** The search field's own prompt, on a list long enough to show one. */
29
+ searchPlaceholder?: string;
30
+ /** `circle` for a face or an avatar, `square` for a logo. */
31
+ shape?: "square" | "circle";
32
+ width?: "sm" | "default" | "lg";
33
+ class?: string;
34
+ };
35
+
36
+ // =========================
37
+ // PROPS
38
+ // =========================
39
+ let {
40
+ options,
41
+ value,
42
+ onSelect,
43
+ ariaLabel,
44
+ placeholder = "Select",
45
+ searchPlaceholder = "Search",
46
+ shape = "square",
47
+ width = "lg",
48
+ class: className,
49
+ }: Props = $props();
50
+
51
+ // =========================
52
+ // DERIVED
53
+ // =========================
54
+ let selected = $derived(options.find((option) => option.value === value));
55
+ </script>
56
+
57
+ <!-- =========================== -->
58
+ <!-- MARKUP -->
59
+ <!-- =========================== -->
60
+ <Picker
61
+ {options}
62
+ {value}
63
+ {onSelect}
64
+ {width}
65
+ {searchPlaceholder}
66
+ triggerClass={cn(pickerTriggerClass(), className)}
67
+ triggerLabel={ariaLabel}
68
+ >
69
+ {#snippet trigger()}
70
+ {#if selected?.image}
71
+ <Mark src={selected.image} label={selected.label} {shape} />
72
+ {:else if selected?.icon}
73
+ {@const Icon = selected.icon}
74
+ <Icon class="text-muted-foreground size-4" />
75
+ {/if}
76
+ <span class="truncate">{selected?.label ?? placeholder}</span>
77
+ <CaretDown class="text-muted-foreground size-3" />
78
+ {/snippet}
79
+ <!-- A row with an icon of its own is drawn by `Picker`; this is every other
80
+ row, the ones that carry a mark. -->
81
+ {#snippet lead(option)}
82
+ <Mark src={option.image} label={option.label} {shape} />
83
+ {/snippet}
84
+ </Picker>
@@ -0,0 +1,143 @@
1
+ <script lang="ts" generics="V extends string">
2
+ import type { Snippet } from "svelte";
3
+ import * as Command from "../../shadcn/command/index.js";
4
+ import * as Popover from "../../shadcn/popover/index.js";
5
+ import { cn } from "@heyramzi/utils";
6
+ import type { PickerGroup, PickerOption } from "./types.js";
7
+
8
+ /**
9
+ * One menu for every "pick one of these" in the app: priority, person, week,
10
+ * goal, status. It's the Command palette at popover scale, same keys.
11
+ */
12
+ // It replaced four pickers in one sidebar, each built differently. Only one
13
+ // had search, only one had arrow keys, and the check mark sat in three places.
14
+
15
+ type Props = {
16
+ /** Flat list. Mutually exclusive with `groups`. */
17
+ options?: PickerOption<V>[];
18
+ /** Titled runs, when the menu has a shape worth naming. */
19
+ groups?: PickerGroup<V>[];
20
+ value: V | null;
21
+ onSelect: (value: V) => void;
22
+ /** The control that opens the menu. Rendered inside `Popover.Trigger`. */
23
+ trigger: Snippet;
24
+ /** Class for the trigger. Use `pickerTriggerClass` to build it. */
25
+ triggerClass?: string;
26
+ triggerLabel?: string;
27
+ /**
28
+ * A mark drawn at the head of a row when the option carries no icon: an
29
+ * avatar, a client logo, a colour swatch.
30
+ */
31
+ lead?: Snippet<[PickerOption<V>]>;
32
+ /** Pinned under the list: a clear action, a hint, a secondary surface. */
33
+ footer?: Snippet<[{ close: () => void }]>;
34
+ /**
35
+ * Search field. `auto` shows it from seven rows, the point at which the
36
+ * eye stops finding a name faster than the fingers can type it.
37
+ */
38
+ searchable?: boolean | "auto";
39
+ searchPlaceholder?: string;
40
+ emptyLabel?: string;
41
+ open?: boolean;
42
+ align?: "start" | "center" | "end";
43
+ /** `sm` fits a word, `default` a name, `lg` a name and a date. */
44
+ width?: "sm" | "default" | "lg";
45
+ disabled?: boolean;
46
+ };
47
+
48
+ let {
49
+ options,
50
+ groups,
51
+ value,
52
+ onSelect,
53
+ trigger,
54
+ triggerClass,
55
+ triggerLabel,
56
+ lead,
57
+ footer,
58
+ searchable = "auto",
59
+ searchPlaceholder,
60
+ emptyLabel = "No matches",
61
+ open = $bindable(false),
62
+ align = "start",
63
+ width = "default",
64
+ disabled = false
65
+ }: Props = $props();
66
+
67
+ let resolved = $derived<PickerGroup<V>[]>(groups ?? [{ options: options ?? [] }]);
68
+ let count = $derived(resolved.reduce((n, g) => n + g.options.length, 0));
69
+ let showSearch = $derived(searchable === "auto" ? count >= 7 : searchable);
70
+
71
+ // 176 / 208 / 240px. The reference runs a 5-row priority menu at 206px, so
72
+ // `default` is the common case. `lg` fits a name and a date without cutting.
73
+ const WIDTH = { sm: "w-44", default: "w-52", lg: "w-60" } as const;
74
+
75
+ function pick(next: V) {
76
+ open = false;
77
+ onSelect(next);
78
+ }
79
+
80
+ /**
81
+ * WHY a custom filter: the default one scores `value`, which is often an id.
82
+ * Typing "an" fuzzy-matched every uuid, so only the visible words are searched.
83
+ */
84
+ function filter(_value: string, search: string, keywords?: string[]) {
85
+ return keywords ? Command.computeCommandScore(keywords.join(" "), search) : 0;
86
+ }
87
+
88
+ function keywordsFor(option: PickerOption<V>): string[] {
89
+ return [option.label, option.hint, option.keywords].filter(Boolean) as string[];
90
+ }
91
+ </script>
92
+
93
+ <Popover.Root bind:open>
94
+ <Popover.Trigger class={triggerClass} aria-label={triggerLabel} {disabled}>
95
+ {@render trigger()}
96
+ </Popover.Trigger>
97
+ <!-- A picker opens below its trigger. `side` was a prop taking four edges and
98
+ no caller in any repo had ever passed one. Deleted 4 Sep 2026. -->
99
+ <Popover.Content {align} side="bottom" class={cn("p-0", WIDTH[width])}>
100
+ <Command.Root {filter} loop>
101
+ {#if showSearch}
102
+ <Command.Input placeholder={searchPlaceholder} />
103
+ {/if}
104
+ <Command.List class={cn(!showSearch && "py-1.5")}>
105
+ {#if showSearch}
106
+ <Command.Empty>{emptyLabel}</Command.Empty>
107
+ {/if}
108
+ {#each resolved as group, i (group.heading ?? i)}
109
+ <Command.Group heading={group.heading}>
110
+ {#each group.options as option (option.value)}
111
+ {@const active = option.value === value}
112
+ <Command.Item
113
+ value={option.value}
114
+ keywords={keywordsFor(option)}
115
+ disabled={option.disabled}
116
+ data-checked={active ? "true" : undefined}
117
+ onSelect={() => pick(option.value)}
118
+ >
119
+ {#if option.icon}
120
+ {@const Icon = option.icon}
121
+ <Icon class={cn("size-4", option.tone)} weight="bold" />
122
+ {:else if lead}
123
+ {@render lead(option)}
124
+ {/if}
125
+ <span class={cn("min-w-0 flex-1 truncate text-left", active && option.tone)}>
126
+ {option.label}
127
+ </span>
128
+ {#if option.hint}
129
+ <span class="text-muted-foreground shrink-0 text-xs tabular-nums">{option.hint}</span>
130
+ {/if}
131
+ </Command.Item>
132
+ {/each}
133
+ </Command.Group>
134
+ {/each}
135
+ </Command.List>
136
+ {#if footer}
137
+ <div class="border-border/70 border-t p-1.5">
138
+ {@render footer({ close: () => (open = false) })}
139
+ </div>
140
+ {/if}
141
+ </Command.Root>
142
+ </Popover.Content>
143
+ </Popover.Root>
@@ -0,0 +1,42 @@
1
+ import { tv } from "tailwind-variants";
2
+
3
+ /**
4
+ * The one look for a control that opens a picker, and for the plain values
5
+ * that sit in the same column beside it.
6
+ */
7
+ // WHY a recipe: a property row can be a menu trigger, a link or a plain
8
+ // string. All three draw the same box, and only one is a button.
9
+
10
+ // It replaced eight geometries in one sidebar: three heights, three radii, two
11
+ // paddings, and a border on three rows out of eight.
12
+
13
+ // Borderless at rest on purpose. A value that's set is a fact to read, so the
14
+ // box shows only under the pointer and on focus.
15
+ export const pickerTriggerClass = tv({
16
+ base: [
17
+ // 13px at weight 500 is Linear's own property row, measured 31 Aug 2026 on a
18
+ // 16px root. 14px reads looser than the reference, and it shows in the sidebar.
19
+ "t-press inline-flex min-w-0 items-center gap-1.5 rounded-md text-[13px] font-medium whitespace-nowrap select-none",
20
+ "hover:bg-muted data-[state=open]:bg-muted",
21
+ "disabled:pointer-events-none disabled:opacity-50",
22
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0",
23
+ ],
24
+ variants: {
25
+ // Heights match `segmentedTrackClass`, so a strip and a picker line up.
26
+ // Padding is 6px leading, 10px trailing: an even 8px shunted icon rows right.
27
+ size: {
28
+ sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-9 any-pointer-coarse:pe-2.5",
29
+ default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-10",
30
+ },
31
+ /**
32
+ * Fill the row. Off by default: Linear's property rows sit at natural width
33
+ * (80px for "Assign"), so a set of rows reads as a set of chips.
34
+ */
35
+ block: { true: "flex w-full", false: "w-fit" },
36
+ /** No value yet: the label is a prompt, so it recedes until hovered. */
37
+ empty: { true: "text-muted-foreground/70 hover:text-muted-foreground", false: "" },
38
+ /** A row that carries no action still needs the column's box. */
39
+ inert: { true: "hover:bg-transparent cursor-default", false: "" },
40
+ },
41
+ defaultVariants: { size: "default", block: false, empty: false, inert: false },
42
+ });
@@ -0,0 +1,26 @@
1
+ import type { PhosphorIcon } from "../../lib/icon-types.js";
2
+
3
+ /** One row in a picker menu. */
4
+ export type PickerOption<V extends string = string> = {
5
+ /** The value written when the row is chosen. */
6
+ value: V;
7
+ label: string;
8
+ /** Right-aligned muted text: a date, a count, a unit. Never a second label. */
9
+ hint?: string;
10
+ /** Phosphor component drawn at the head of the row. */
11
+ icon?: PhosphorIcon;
12
+ /** A mark drawn at the head of a row that has no icon: a logo, an avatar.
13
+ * Drawn by whatever fills `lead`, which is what `MarkPicker` is for. */
14
+ image?: string;
15
+ /** Tone for the icon and, when the row is the current value, for the label. */
16
+ tone?: string;
17
+ /** Extra words the search matches, so "team@" finds a person by email. */
18
+ keywords?: string;
19
+ disabled?: boolean;
20
+ };
21
+
22
+ /** A titled run of options. An untitled group is seamless; a titled one rules off. */
23
+ export type PickerGroup<V extends string = string> = {
24
+ heading?: string;
25
+ options: PickerOption<V>[];
26
+ };
@@ -0,0 +1,175 @@
1
+ <script lang="ts" module>
2
+ import type { SegmentContent } from "./segment.svelte";
3
+
4
+ /** One tab. See `SegmentContent` for everything a segment can say. */
5
+ export type TabDef<T extends string = string> = SegmentContent & { value: T };
6
+
7
+ /**
8
+ * Where the tab row sits, the only thing that legitimately differs between
9
+ * two tab sets. A closed union and not a class string: 23 files each decided
10
+ * their own.
11
+ */
12
+ export type TabsPlacement = "toolbar" | "section" | "inline";
13
+
14
+ const PLACEMENT_WRAPPER: Record<TabsPlacement, string | undefined> = {
15
+ toolbar: undefined,
16
+ section: "shrink-0 border-b border-border py-2",
17
+ inline: undefined,
18
+ };
19
+
20
+ const PLACEMENT_ROOT: Record<TabsPlacement, string | undefined> = {
21
+ toolbar: undefined,
22
+ section: "gap-0",
23
+ inline: "shrink-0",
24
+ };
25
+ </script>
26
+
27
+ <script lang="ts" generics="T extends string">
28
+ import { Tabs as TabsPrimitive } from "bits-ui";
29
+ import { cn } from "@heyramzi/utils";
30
+ import Segment from "./segment.svelte";
31
+ import SegmentedTrack from "./segmented-track.svelte";
32
+ import SegmentedOverflow from "./segmented-overflow.svelte";
33
+ import { SEGMENT_MAX, splitSegments } from "./collapse.js";
34
+ import {
35
+ segmentedActiveClass,
36
+ segmentedTriggerClass,
37
+ type SegmentedVariant,
38
+ } from "./segmented.js";
39
+
40
+ // WHY re-declare `value` and `onValueChange`: the primitive types them as a
41
+ // bare `string`, so fourteen pages cast back to their union. Generic now, the
42
+ // one narrowing happens here.
43
+ type Props = Omit<TabsPrimitive.RootProps, "value" | "onValueChange"> & {
44
+ tabs: readonly TabDef<T>[];
45
+ value?: T;
46
+ onValueChange?: (value: T) => void;
47
+ // Where the row sits. See `TabsPlacement`.
48
+ placement?: TabsPlacement;
49
+ // WHY: when true the track stretches to its container and triggers share width equally.
50
+ // Use for narrow side panels where pickers should fill the row.
51
+ block?: boolean;
52
+ // `underline`: bare labels over a hairline, a travelling rule marks the active one.
53
+ // Same height and overflow menu. Colour the rule with `--segmented-rule` on `class`.
54
+ variant?: SegmentedVariant;
55
+ ariaLabel?: string;
56
+ toolbar?: import("svelte").Snippet;
57
+ // WHY: content ABOVE the tab row but inside the root. A detail pane names the
58
+ // record first, and the row can't leave the root without losing its tabpanel wiring.
59
+ lead?: import("svelte").Snippet;
60
+ // The page's own horizontal gutter, merged over the placement chrome.
61
+ listWrapperClass?: string;
62
+ // WHY: when only one tab is left the pill row is just decoration. Opt in to
63
+ // drop the row entirely (the lone tab's content still renders).
64
+ hideSingle?: boolean;
65
+ // Objects the row may draw before its tail becomes a menu, the menu
66
+ // counted as one of them. See `collapse.ts`.
67
+ max?: number;
68
+ // What the menu segment says. Translate at the call site.
69
+ moreLabel?: string;
70
+ };
71
+
72
+ let {
73
+ ref = $bindable(null),
74
+ value = $bindable(),
75
+ onValueChange,
76
+ tabs,
77
+ placement = "toolbar",
78
+ block = false,
79
+ variant = "pill",
80
+ ariaLabel,
81
+ toolbar,
82
+ lead,
83
+ class: className,
84
+ listWrapperClass,
85
+ hideSingle = false,
86
+ max = SEGMENT_MAX,
87
+ moreLabel,
88
+ children,
89
+ ...restProps
90
+ }: Props = $props();
91
+
92
+ let split = $derived(
93
+ splitSegments(
94
+ tabs,
95
+ max,
96
+ tabs.findIndex((tab) => tab.value === value),
97
+ ),
98
+ );
99
+ </script>
100
+
101
+ <TabsPrimitive.Root
102
+ bind:ref
103
+ value={value ?? ""}
104
+ onValueChange={(next) => {
105
+ // The single narrowing: bits-ui hands back the string it was given, which
106
+ // is one of `tabs[].value`, so it is a `T`.
107
+ const picked = next as T;
108
+ value = picked;
109
+ onValueChange?.(picked);
110
+ }}
111
+ data-slot="tabs"
112
+ class={cn(
113
+ "group/tabs flex max-w-full min-w-0 flex-col gap-2",
114
+ PLACEMENT_ROOT[placement],
115
+ className,
116
+ )}
117
+ {...restProps}
118
+ >
119
+ {@render lead?.()}
120
+
121
+ {#if !(hideSingle && tabs.length <= 1)}
122
+ <!-- WHY scroll, not wrap: it's one object. Seven categories ran 828px,
123
+ past an iPad's edge. Past `max` the tail collapses first. -->
124
+ <div
125
+ class={cn(
126
+ "no-scrollbar flex max-w-full min-w-0 items-center gap-3 overflow-x-auto",
127
+ PLACEMENT_WRAPPER[placement],
128
+ listWrapperClass,
129
+ )}
130
+ >
131
+ <SegmentedTrack fill={block} {variant}>
132
+ <!-- WHY an inner `display: contents` tablist: a tablist owns only tabs, and the
133
+ overflow trigger isn't one. `contents` keeps segments as direct flex children. -->
134
+ <div class="contents" data-slot="tabs-list" role="tablist" aria-label={ariaLabel}>
135
+ {#each split.visible as tab (tab.value)}
136
+ <TabsPrimitive.Trigger
137
+ value={tab.value}
138
+ class={cn(segmentedTriggerClass({ variant, fill: block }), segmentedActiveClass)}
139
+ >
140
+ <Segment
141
+ label={tab.label}
142
+ count={tab.count}
143
+ icon={tab.icon}
144
+ image={tab.image}
145
+ done={tab.done}
146
+ badge={tab.badge}
147
+ active="auto"
148
+ />
149
+ </TabsPrimitive.Trigger>
150
+ {/each}
151
+ </div>
152
+ {#snippet after()}
153
+ {#if split.hidden.length > 0}
154
+ <SegmentedOverflow
155
+ items={split.hidden}
156
+ onSelect={(next) => {
157
+ value = next;
158
+ onValueChange?.(next);
159
+ }}
160
+ label={moreLabel}
161
+ fill={block}
162
+ {variant}
163
+ />
164
+ {/if}
165
+ {/snippet}
166
+ </SegmentedTrack>
167
+
168
+ {#if toolbar}
169
+ {@render toolbar()}
170
+ {/if}
171
+ </div>
172
+ {/if}
173
+
174
+ {@render children?.()}
175
+ </TabsPrimitive.Root>
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { splitSegments } from "./collapse.js";
3
+
4
+ const items = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
5
+
6
+ describe("splitSegments", () => {
7
+ it("keeps everything when the row can hold it", () => {
8
+ expect(splitSegments(items.slice(0, 6), 6, 0)).toEqual({
9
+ visible: ["a", "b", "c", "d", "e", "f"],
10
+ hidden: [],
11
+ });
12
+ });
13
+
14
+ it("leaves one slot for the menu once the row overflows", () => {
15
+ const { visible, hidden } = splitSegments(items, 6, 0);
16
+ expect(visible).toEqual(["a", "b", "c", "d", "e"]);
17
+ expect(hidden).toEqual(["f", "g", "h"]);
18
+ });
19
+
20
+ it("pulls the current item into the last visible slot", () => {
21
+ const { visible, hidden } = splitSegments(items, 6, 6);
22
+ expect(visible).toEqual(["a", "b", "c", "d", "g"]);
23
+ expect(hidden).toEqual(["e", "f", "h"]);
24
+ });
25
+
26
+ it("loses nothing whichever item is current", () => {
27
+ for (let i = 0; i < items.length; i++) {
28
+ const { visible, hidden } = splitSegments(items, 6, i);
29
+ expect([...visible, ...hidden].sort()).toEqual([...items].sort());
30
+ expect(visible).toContain(items[i]);
31
+ expect(visible).toHaveLength(5);
32
+ }
33
+ });
34
+
35
+ it("draws the whole strip when nothing is current and it fits", () => {
36
+ expect(splitSegments(items.slice(0, 3), 6, -1).hidden).toEqual([]);
37
+ });
38
+
39
+ it("collapses the tail when nothing is current", () => {
40
+ expect(splitSegments(items, 6, -1)).toEqual({
41
+ visible: ["a", "b", "c", "d", "e"],
42
+ hidden: ["f", "g", "h"],
43
+ });
44
+ });
45
+
46
+ it("refuses to split into a menu plus one segment", () => {
47
+ expect(splitSegments(items, 2, 5).hidden).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,39 @@
1
+ /** How a segmented strip that outgrew its row decides what to keep. */
2
+
3
+ // WHY not a scrollbar: a sideways scroll hid four of five tabs
4
+ // behind a gesture nobody makes with a mouse. The tail becomes a menu instead.
5
+
6
+ // WHY a count, not a measurement: the strip sits in a toolbar sized by its own
7
+ // content, so measuring reads its own width back and never collapses.
8
+
9
+ /**
10
+ * The most objects a strip draws before its tail becomes a menu, the menu
11
+ * counted as one of them. Two to six values is where a segmented control reads best.
12
+ */
13
+ export const SEGMENT_MAX = 6;
14
+
15
+ /**
16
+ * Split items into the drawn ones and the menu's. The current item always stays visible.
17
+ * @param max objects the row may draw, the menu counted as one
18
+ */
19
+ export function splitSegments<S>(
20
+ items: readonly S[],
21
+ max: number,
22
+ activeIndex: number,
23
+ ): { visible: S[]; hidden: S[] } {
24
+ // Under three the row is the menu and a single segment beside it, which says
25
+ // less than the menu alone. Below that, and whenever everything fits, no split.
26
+ if (max < 3 || items.length <= max) return { visible: [...items], hidden: [] };
27
+
28
+ const slots = max - 1;
29
+ if (activeIndex < slots && activeIndex >= 0) {
30
+ return { visible: items.slice(0, slots), hidden: items.slice(slots) };
31
+ }
32
+ if (activeIndex < 0 || activeIndex >= items.length) {
33
+ return { visible: items.slice(0, slots), hidden: items.slice(slots) };
34
+ }
35
+ return {
36
+ visible: [...items.slice(0, slots - 1), ...items.slice(activeIndex, activeIndex + 1)],
37
+ hidden: items.filter((_, i) => i >= slots - 1 && i !== activeIndex),
38
+ };
39
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The segmented control. Three components draw it, one per ARIA shape, and they
3
+ * share the track (`segmented.ts`) and segment body (`segment.svelte`).
4
+ */
5
+
6
+ // WHY no low-level `Tabs.Root`/`List`/`Trigger` here: that's `shadcn/tabs`. This
7
+ // is the data-driven layer on top, and `AnimatedTabs` is the way in.
8
+
9
+ // Past `SEGMENT_MAX` objects the tail collapses into a menu: `collapse.ts`
10
+ // decides, `segmented-overflow.svelte` draws it. No call site opts in.
11
+ import AnimatedTabs, {
12
+ type TabDef,
13
+ type TabsPlacement,
14
+ } from "./animated-tabs.svelte";
15
+ import SegmentedFilter from "./segmented-filter.svelte";
16
+ import SegmentedNav, {
17
+ type SegmentedNavItem,
18
+ } from "./segmented-nav.svelte";
19
+ import SectionRuns, {
20
+ type SectionRun,
21
+ type SectionStrip,
22
+ } from "./section-runs.svelte";
23
+ import { type SegmentContent } from "./segment.svelte";
24
+ import { segmentedRowClass, type SegmentedVariant } from "./segmented.js";
25
+ import { SEGMENT_MAX } from "./collapse.js";
26
+
27
+ export {
28
+ AnimatedTabs,
29
+ SectionRuns,
30
+ SegmentedFilter,
31
+ SegmentedNav,
32
+ SEGMENT_MAX,
33
+ segmentedRowClass,
34
+ type SectionRun,
35
+ type SectionStrip,
36
+ type SegmentContent,
37
+ type SegmentedNavItem,
38
+ type SegmentedVariant,
39
+ type TabDef,
40
+ type TabsPlacement,
41
+ };
@@ -0,0 +1,79 @@
1
+ <script lang="ts" module>
2
+ import type { SegmentedNavItem } from "./segmented-nav.svelte";
3
+
4
+ /** One bordered track on the section row. A run with a `label` is a reference
5
+ * set, not a step, so `SectionRuns` titles it and sends it right. */
6
+ export interface SectionRun {
7
+ label?: string;
8
+ items: readonly SegmentedNavItem[];
9
+ /** Overrides the strip's `current` for this run, so two nested runs each light one tab. */
10
+ current?: string;
11
+ }
12
+
13
+ // WHY runs: a section can hold a flow and the references it's read against.
14
+ // Each run gets its own track so they don't read as one sequence.
15
+ /** What an app shell publishes so a page's toolbar can draw the section strip. */
16
+ export interface SectionStrip {
17
+ runs: readonly SectionRun[];
18
+ current: string;
19
+ ariaLabel: string;
20
+ }
21
+ </script>
22
+
23
+ <script lang="ts">
24
+ // =========================
25
+ // IMPORTS
26
+ // =========================
27
+ import { cn } from "@heyramzi/utils";
28
+ import SegmentedNav from "./segmented-nav.svelte";
29
+
30
+ // =========================
31
+ // TYPES
32
+ // =========================
33
+ interface Props {
34
+ strip: SectionStrip;
35
+ /** The host row's own constraints: order, width, container queries. */
36
+ class?: string;
37
+ }
38
+
39
+ // =========================
40
+ // PROPS
41
+ // =========================
42
+ let { strip, class: className }: Props = $props();
43
+
44
+ // =========================
45
+ // DERIVED
46
+ // =========================
47
+
48
+ // The titled run takes the right edge, so a reference set reads apart from the steps.
49
+ let firstTitled = $derived(strip.runs.findIndex((run) => run.label));
50
+ </script>
51
+
52
+ <!-- =========================== -->
53
+ <!-- MARKUP -->
54
+ <!-- =========================== -->
55
+
56
+ <!-- WHY it grows only with a titled run: `ml-auto` needs room to push into, and
57
+ a one-run strip keeps its width beside the search. -->
58
+ <div
59
+ class={cn(
60
+ "no-scrollbar flex min-w-0 items-center gap-x-3 overflow-x-auto",
61
+ firstTitled >= 0 && "flex-1",
62
+ className,
63
+ )}
64
+ >
65
+ {#each strip.runs as run, index (index)}
66
+ <div class={cn("flex shrink-0 items-center gap-2", index === firstTitled && "ml-auto")}>
67
+ {#if run.label}
68
+ <span class="text-[10px] font-medium tracking-wider text-muted-foreground uppercase">
69
+ {run.label}
70
+ </span>
71
+ {/if}
72
+ <SegmentedNav
73
+ items={run.items}
74
+ current={run.current ?? strip.current}
75
+ ariaLabel={run.label ?? strip.ariaLabel}
76
+ />
77
+ </div>
78
+ {/each}
79
+ </div>