@heyramzi/ui 7.0.0 → 8.1.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 (259) hide show
  1. package/COMPONENTS.md +28 -17
  2. package/README.md +6 -2
  3. package/package.json +25 -15
  4. package/scripts/sync-shadcn.mjs +17 -13
  5. package/scripts/sync-transitions.mjs +11 -11
  6. package/src/agents/agent-avatar.svelte +67 -114
  7. package/src/agents/agent-cast.svelte +31 -7
  8. package/src/agents/demo.svelte +55 -8
  9. package/src/agents/helpers.ts +58 -0
  10. package/src/agents/page-state.ts +70 -0
  11. package/src/agents/types.ts +3 -1
  12. package/src/ai/ai-button/AiButton.svelte +86 -0
  13. package/src/ai/ai-button/index.ts +4 -0
  14. package/src/ai/confirmation/Confirmation.svelte +8 -8
  15. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  16. package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
  17. package/src/ai/generation-progress/index.ts +7 -0
  18. package/src/ai/index.ts +16 -6
  19. package/src/ai/prompt-input/PromptInput.svelte +19 -1
  20. package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
  21. package/src/ai/reasoning/Reasoning.svelte +11 -8
  22. package/src/ambient.d.ts +4 -0
  23. package/src/animations/README.md +14 -14
  24. package/src/animations/index.css +3 -14
  25. package/src/animations/upstream-coverage.test.ts +1 -1
  26. package/src/components/copy/clipboard.test.ts +206 -0
  27. package/src/components/copy/clipboard.ts +49 -0
  28. package/src/components/copy/copied.svelte.ts +39 -0
  29. package/src/components/copy/copy-button.svelte +175 -0
  30. package/src/components/copy/copy-button.test.ts +110 -0
  31. package/src/components/copy/index.ts +3 -0
  32. package/src/components/data-table/data-table.test.ts +134 -0
  33. package/src/components/data-table/data-table.ts +77 -0
  34. package/src/components/data-table/index.ts +20 -0
  35. package/src/components/data-table/infinite-scroll.svelte +69 -0
  36. package/src/components/data-table/pagination.svelte +62 -0
  37. package/src/components/data-table/row-arrow.svelte +28 -0
  38. package/src/components/data-table/row-context-menu.svelte +106 -0
  39. package/src/components/data-table/row-context-menu.test.ts +18 -0
  40. package/src/components/data-table/sortable-head.svelte +56 -0
  41. package/src/components/data-table/table-skeleton.svelte +83 -0
  42. package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
  43. package/src/components/hold-to-confirm/index.ts +1 -0
  44. package/src/components/icon/icon.svelte +23 -0
  45. package/src/components/icon/index.ts +1 -0
  46. package/src/components/language-flag/index.ts +1 -0
  47. package/src/components/language-flag/language-flag.svelte +60 -0
  48. package/src/components/list-page/detail-empty.svelte +50 -0
  49. package/src/components/list-page/detail-header.svelte +107 -0
  50. package/src/components/list-page/detail-panel.svelte +90 -0
  51. package/src/components/list-page/detail-row.svelte +36 -0
  52. package/src/components/list-page/detail-stack.svelte +33 -0
  53. package/src/components/list-page/empty-state.svelte +91 -0
  54. package/src/components/list-page/filter-item.svelte +49 -0
  55. package/src/components/list-page/filter-popover.svelte +88 -0
  56. package/src/components/list-page/filter-section.ts +12 -0
  57. package/src/components/list-page/grouped-list.svelte +116 -0
  58. package/src/components/list-page/index.ts +28 -0
  59. package/src/components/list-page/list-page-shell.svelte +195 -0
  60. package/src/components/list-page/list-table.ts +19 -0
  61. package/src/components/list-page/master-detail.svelte +206 -0
  62. package/src/components/list-page/master-detail.test.ts +37 -0
  63. package/src/components/list-page/notice-bar.svelte +68 -0
  64. package/src/components/list-page/prev-next.svelte +104 -0
  65. package/src/components/list-page/queue-rail.ts +25 -0
  66. package/src/components/list-page/rail-body.svelte +29 -0
  67. package/src/components/list-page/rail-empty.svelte +51 -0
  68. package/src/components/list-page/rail-head.svelte +49 -0
  69. package/src/components/list-page/rail-row.svelte +163 -0
  70. package/src/components/list-page/section-host.ts +24 -0
  71. package/src/components/list-page/section-label.svelte +39 -0
  72. package/src/components/list-page/step.test.ts +21 -0
  73. package/src/components/list-page/step.ts +10 -0
  74. package/src/components/list-page/toolbar-search.svelte +63 -0
  75. package/src/components/list-page/toolbar-trigger.ts +23 -0
  76. package/src/components/list-page/types.ts +1 -0
  77. package/src/components/locale-switch/index.ts +1 -0
  78. package/src/components/locale-switch/locale-switch.svelte +59 -0
  79. package/src/components/mark/index.ts +1 -0
  80. package/src/components/mark/mark.svelte +70 -0
  81. package/src/components/open-in/index.ts +6 -0
  82. package/src/components/open-in/open-in.svelte +106 -0
  83. package/src/components/picker/index.ts +4 -0
  84. package/src/components/picker/mark-picker.svelte +84 -0
  85. package/src/components/picker/picker.svelte +143 -0
  86. package/src/components/picker/trigger.ts +42 -0
  87. package/src/components/picker/types.ts +26 -0
  88. package/src/components/segmented/animated-tabs.svelte +175 -0
  89. package/src/components/segmented/collapse.test.ts +49 -0
  90. package/src/components/segmented/collapse.ts +39 -0
  91. package/src/components/segmented/index.ts +41 -0
  92. package/src/components/segmented/section-runs.svelte +79 -0
  93. package/src/components/segmented/segment.svelte +106 -0
  94. package/src/components/segmented/segmented-filter.svelte +125 -0
  95. package/src/components/segmented/segmented-nav.svelte +82 -0
  96. package/src/components/segmented/segmented-overflow.svelte +85 -0
  97. package/src/components/segmented/segmented-track.svelte +181 -0
  98. package/src/components/segmented/segmented.test.ts +20 -0
  99. package/src/components/segmented/segmented.ts +80 -0
  100. package/src/components/star-rating-input/index.ts +9 -0
  101. package/src/components/star-rating-input/star-rating-input.svelte +256 -0
  102. package/src/components/svg/index.ts +1 -0
  103. package/src/components/svg/svg.svelte +9 -0
  104. package/src/effects/border-beam/BorderBeam.svelte +1 -1
  105. package/src/effects/image-comparison/ImageComparison.css +42 -8
  106. package/src/effects/image-comparison/ImageComparison.svelte +138 -21
  107. package/src/effects/image-comparison/compare.test.ts +17 -0
  108. package/src/effects/image-comparison/compare.ts +5 -0
  109. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
  110. package/src/effects/magic-card/MagicCard.css +47 -0
  111. package/src/effects/magic-card/MagicCard.svelte +57 -0
  112. package/src/effects/marquee/Marquee.css +27 -1
  113. package/src/effects/marquee/Marquee.svelte +67 -3
  114. package/src/effects/reveal/index.ts +2 -1
  115. package/src/effects/reveal/observe.test.ts +60 -0
  116. package/src/effects/reveal/observe.ts +33 -1
  117. package/src/effects/reveal/types.ts +7 -0
  118. package/src/effects/theme-toggle/ThemeToggle.css +199 -6
  119. package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
  120. package/src/gallery.test.ts +52 -0
  121. package/src/index.ts +5 -1
  122. package/src/lib/panel.ts +7 -1
  123. package/src/lib/theme-transition.ts +37 -11
  124. package/src/lib/tone.ts +48 -0
  125. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  126. package/src/shadcn/alert/alert.svelte +3 -1
  127. package/src/shadcn/badge/badge.svelte +8 -7
  128. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  129. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  130. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  131. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  132. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  133. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  134. package/src/shadcn/calendar/calendar.svelte +6 -0
  135. package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
  136. package/src/shadcn/command/index.ts +3 -0
  137. package/src/shadcn/field/index.ts +3 -0
  138. package/src/shadcn/field/simple-field.svelte +71 -0
  139. package/src/shadcn/field/simple-field.test.ts +47 -0
  140. package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
  141. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  142. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  143. package/src/shadcn/sheet/sheet.css +48 -0
  144. package/src/shadcn/sonner/sonner.svelte +7 -10
  145. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  146. package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
  147. package/src/ai/composer/Composer.css +0 -108
  148. package/src/ai/composer/Composer.svelte +0 -111
  149. package/src/ai/composer/index.ts +0 -10
  150. package/src/animations/accordion.css +0 -58
  151. package/src/animations/icon-swap.css +0 -43
  152. package/src/animations/notification-badge.css +0 -81
  153. package/src/animations/panel.css +0 -86
  154. package/src/animations/reasoning-stream.css +0 -49
  155. package/src/animations/skeleton-reveal.css +0 -97
  156. package/src/animations/streaming-text.css +0 -31
  157. package/src/animations/tabs-sliding.css +0 -83
  158. package/src/animations/texts-reveal.css +0 -70
  159. package/src/animations/tooltip.css +0 -72
  160. package/src/animations/view-transition-reveal.ts +0 -74
  161. package/src/lib/utils.ts +0 -45
  162. package/src/showcase/AiSection.svelte +0 -150
  163. package/src/showcase/AnimationsSection.svelte +0 -1658
  164. package/src/showcase/EffectsSection.svelte +0 -152
  165. package/src/showcase/ShadcnSection.svelte +0 -27
  166. package/src/showcase/ShowcaseImport.svelte +0 -126
  167. package/src/showcase/ShowcaseList.svelte +0 -132
  168. package/src/showcase/ShowcaseNav.svelte +0 -247
  169. package/src/showcase/ShowcaseSection.svelte +0 -112
  170. package/src/showcase/ShowcaseShell.svelte +0 -55
  171. package/src/showcase/ShowcaseStage.svelte +0 -342
  172. package/src/showcase/ShowcaseVariant.svelte +0 -109
  173. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  174. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  175. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  176. package/src/showcase/check.svg +0 -1
  177. package/src/showcase/chevron-arms.svg +0 -2
  178. package/src/showcase/chevron-down.svg +0 -1
  179. package/src/showcase/copy.svg +0 -2
  180. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  181. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  182. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  183. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  184. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  185. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  186. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  187. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  188. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  189. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  190. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  191. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  192. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  193. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  194. package/src/showcase/demos/effects/in-view.svelte +0 -41
  195. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  196. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  197. package/src/showcase/demos/effects/marquee.svelte +0 -33
  198. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  199. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  200. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  201. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  202. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  203. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  204. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  205. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  206. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  207. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  208. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  209. package/src/showcase/demos/shadcn/button.svelte +0 -11
  210. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  211. package/src/showcase/demos/shadcn/card.svelte +0 -39
  212. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  213. package/src/showcase/demos/shadcn/code-block.svelte +0 -80
  214. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  215. package/src/showcase/demos/shadcn/command.svelte +0 -48
  216. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  217. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  218. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  219. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  220. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  221. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  222. package/src/showcase/demos/shadcn/field.svelte +0 -115
  223. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  224. package/src/showcase/demos/shadcn/index.ts +0 -382
  225. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  226. package/src/showcase/demos/shadcn/input.svelte +0 -5
  227. package/src/showcase/demos/shadcn/label.svelte +0 -11
  228. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  229. package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
  230. package/src/showcase/demos/shadcn/message.svelte +0 -75
  231. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  232. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  233. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  234. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  235. package/src/showcase/demos/shadcn/select.svelte +0 -29
  236. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  237. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  238. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  239. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  240. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  241. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  242. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  243. package/src/showcase/demos/shadcn/table.svelte +0 -76
  244. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  245. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  246. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  247. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  248. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  249. package/src/showcase/demos/shadcn/trace.svelte +0 -51
  250. package/src/showcase/heart.svg +0 -1
  251. package/src/showcase/index.ts +0 -38
  252. package/src/showcase/kit.test.ts +0 -72
  253. package/src/showcase/kit.ts +0 -16
  254. package/src/showcase/plus.svg +0 -1
  255. package/src/showcase/showcase-shell.css +0 -91
  256. package/src/showcase/stacked-section.css +0 -15
  257. package/src/showcase/success-check.svg +0 -1
  258. package/src/showcase/types.ts +0 -29
  259. 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>