@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,106 @@
1
+ <script lang="ts" module>
2
+ import type { PhosphorIcon } from "../../lib/icon-types.js";
3
+
4
+ /** Everything a segment may say, for every strip drawn from this folder. */
5
+
6
+ // WHY one shape: the tab, nav and filter strips each grew an item type and
7
+ // drifted, down to drawing a logo fallback two ways. Now it's one type, one renderer.
8
+ export type SegmentContent = {
9
+ label: string;
10
+ /** A number after the label. `0` still renders; omit for "no number". */
11
+ count?: number;
12
+ icon?: PhosphorIcon;
13
+ /** A square mark drawn in place of an icon, for a brand that has one. */
14
+ image?: string;
15
+ /**
16
+ * This segment's work is finished. Drawn as a dot after the label, and only
17
+ * where there is no count, because a count already says the same thing.
18
+ */
19
+ done?: boolean;
20
+ /**
21
+ * A short note after the label, such as "Save 20%". Text where `count` is a
22
+ * number, in the accent colour. Drawn before a count or a done dot.
23
+ */
24
+ badge?: string;
25
+ };
26
+ </script>
27
+
28
+ <script lang="ts">
29
+ // =========================
30
+ // IMPORTS
31
+ // =========================
32
+ import { cn } from "@heyramzi/utils";
33
+ import { Icon } from "../icon/index.js";
34
+ import { Mark } from "../mark/index.js";
35
+
36
+ // =========================
37
+ // TYPES
38
+ // =========================
39
+ type Props = SegmentContent & {
40
+ /**
41
+ * `true`/`false` when the caller owns the state. `"auto"` when bits-ui owns it
42
+ * in a `data-state` on the trigger, and the stylesheet answers.
43
+ */
44
+ active?: boolean | "auto";
45
+ };
46
+
47
+ // =========================
48
+ // PROPS
49
+ // =========================
50
+ let { label, count, icon, image, done = false, badge, active = false }: Props = $props();
51
+
52
+ // =========================
53
+ // DERIVED
54
+ // =========================
55
+ let auto = $derived(active === "auto");
56
+ let on = $derived(active === true);
57
+ </script>
58
+
59
+ <!-- =========================== -->
60
+ <!-- MARKUP -->
61
+ <!-- =========================== -->
62
+ {#if image}
63
+ <Mark
64
+ src={image}
65
+ {label}
66
+ class={cn(
67
+ "opacity-75 transition-opacity duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/segment:opacity-100",
68
+ on && "opacity-100",
69
+ auto && "group-data-[state=active]/segment:opacity-100",
70
+ )}
71
+ />
72
+ {:else if icon}
73
+ <Icon {icon} size={16} class="shrink-0" />
74
+ {/if}
75
+
76
+ {label}
77
+
78
+ {#if badge}
79
+ <span class="text-primary text-[10px] font-semibold tracking-wide whitespace-nowrap uppercase">{badge}</span>
80
+ {/if}
81
+
82
+ {#if count != null}
83
+ <span
84
+ class={cn(
85
+ // WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill.
86
+
87
+ // WHY min-width equals height with 4px padding: `min-w-5 px-1.5` made a digit
88
+ // 20 by 18, so `rounded-full` drew an ellipse.
89
+ "inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-medium tabular-nums",
90
+ "bg-muted-foreground/15 text-muted-foreground",
91
+ on && "bg-brand-indigo/14 text-brand-indigo",
92
+ auto && "group-data-[state=active]/segment:bg-brand-indigo/14 group-data-[state=active]/segment:text-brand-indigo",
93
+ )}
94
+ >
95
+ {count}
96
+ </span>
97
+ {:else if done}
98
+ <span
99
+ class={cn(
100
+ "size-1.5 shrink-0 rounded-full bg-muted-foreground/50",
101
+ on && "bg-brand-indigo",
102
+ auto && "group-data-[state=active]/segment:bg-brand-indigo",
103
+ )}
104
+ aria-hidden="true"
105
+ ></span>
106
+ {/if}
@@ -0,0 +1,125 @@
1
+ <script lang="ts" generics="T extends string">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { cn } from "@heyramzi/utils";
6
+ import Segment from "./segment.svelte";
7
+ import type { TabDef } from "./animated-tabs.svelte";
8
+ import SegmentedTrack from "./segmented-track.svelte";
9
+ import SegmentedOverflow from "./segmented-overflow.svelte";
10
+ import { SEGMENT_MAX, splitSegments } from "./collapse.js";
11
+ import { segmentedTriggerClass } from "./segmented.js";
12
+
13
+ // =========================
14
+ // TYPES
15
+ // =========================
16
+ type Props = {
17
+ // WHY: picks which rows are listed, with no panel of its own. That's a
18
+ // segmented control, not a tab set.
19
+
20
+ // WHY not `AnimatedTabs`: a strip with no tabpanel is a `group` of `aria-pressed`
21
+ // toggles. "Tab 1 of 4" sends a screen reader looking for a panel.
22
+
23
+ // WHY `TabDef`: an option and a tab were the same five fields written twice.
24
+ options: readonly TabDef<T>[];
25
+ value: T;
26
+ // For a caller whose value is derived and can't be bound. Same contract as
27
+ // `AnimatedTabs`, so the two are picked by ARIA alone.
28
+ onValueChange?: (value: T) => void;
29
+ // Count per option, keyed by value. Merged over `options[].count`, because
30
+ // the counts usually arrive from the data while the labels are a constant.
31
+ counts?: Partial<Record<T, number>>;
32
+ ariaLabel: string;
33
+ /** Fill the row rather than sit at its natural width. On for the list-page
34
+ * filter strip, off for a segment sitting in a toolbar beside others. */
35
+ block?: boolean;
36
+ /** Objects the row may draw before its tail becomes a menu. See `collapse.ts`. */
37
+ max?: number;
38
+ /** What the menu segment says. Translate at the call site. */
39
+ moreLabel?: string;
40
+ class?: string;
41
+ };
42
+
43
+ // =========================
44
+ // PROPS
45
+ // =========================
46
+ let {
47
+ options,
48
+ value = $bindable(),
49
+ onValueChange,
50
+ counts,
51
+ ariaLabel,
52
+ block = true,
53
+ max = SEGMENT_MAX,
54
+ moreLabel,
55
+ class: className,
56
+ }: Props = $props();
57
+
58
+ // =========================
59
+ // DERIVED
60
+ // =========================
61
+
62
+ // Counts arrive from data, labels are constant. Merged once here, so the pill
63
+ // and the overflow hint say the same thing.
64
+ let merged = $derived(
65
+ options.map((option) => {
66
+ const count = counts?.[option.value] ?? option.count;
67
+ return count === option.count ? option : { ...option, count };
68
+ }),
69
+ );
70
+ let split = $derived(
71
+ splitSegments(
72
+ merged,
73
+ max,
74
+ merged.findIndex((option) => option.value === value),
75
+ ),
76
+ );
77
+
78
+ function pick(next: T) {
79
+ value = next;
80
+ onValueChange?.(next);
81
+ }
82
+ </script>
83
+
84
+ <!-- =========================== -->
85
+ <!-- MARKUP -->
86
+ <!-- =========================== -->
87
+ <!-- WHY scroll, not wrap: it's one object. Past `max` the tail collapses into
88
+ a menu first, so scrolling is the last resort. -->
89
+ <div
90
+ class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
91
+ role="group"
92
+ aria-label={ariaLabel}
93
+ >
94
+ <SegmentedTrack fill={block}>
95
+ {#each split.visible as option (option.value)}
96
+ {@const active = value === option.value}
97
+ <button
98
+ type="button"
99
+ class={segmentedTriggerClass({ fill: block, active })}
100
+ onclick={() => pick(option.value)}
101
+ aria-pressed={active}
102
+ >
103
+ <Segment
104
+ label={option.label}
105
+ count={option.count}
106
+ icon={option.icon}
107
+ image={option.image}
108
+ done={option.done}
109
+ badge={option.badge}
110
+ {active}
111
+ />
112
+ </button>
113
+ {/each}
114
+ {#snippet after()}
115
+ {#if split.hidden.length > 0}
116
+ <SegmentedOverflow
117
+ items={split.hidden}
118
+ onSelect={pick}
119
+ label={moreLabel}
120
+ fill={block}
121
+ />
122
+ {/if}
123
+ {/snippet}
124
+ </SegmentedTrack>
125
+ </div>
@@ -0,0 +1,82 @@
1
+ <script lang="ts" module>
2
+ import type { SegmentContent } from "./segment.svelte";
3
+
4
+ /** One destination. See `SegmentContent` for everything a segment can say. */
5
+ export type SegmentedNavItem = SegmentContent & { href: string };
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ // =========================
10
+ // IMPORTS
11
+ // =========================
12
+ import { cn } from "@heyramzi/utils";
13
+ import Segment from "./segment.svelte";
14
+ import SegmentedTrack from "./segmented-track.svelte";
15
+ import { segmentedTriggerClass } from "./segmented.js";
16
+
17
+ // =========================
18
+ // TYPES
19
+ // =========================
20
+ type Props = {
21
+ // WHY a third component: tabs are a `tablist`, filters a `group` of toggles,
22
+ // and this goes to sibling pages, so it's a `nav` of links with `aria-current`.
23
+
24
+ // Links give middle-click, bookmarks and the back button. The look comes from
25
+ // `segmented.ts` and `segment.svelte`, so nothing here can drift.
26
+ items: readonly SegmentedNavItem[];
27
+ /** The href that is current. Compared after trailing slashes are dropped. */
28
+ current: string;
29
+ ariaLabel: string;
30
+ /** Fill the row rather than sit at its natural width. */
31
+ block?: boolean;
32
+ class?: string;
33
+ };
34
+
35
+ // =========================
36
+ // PROPS
37
+ // =========================
38
+ let {
39
+ items,
40
+ current,
41
+ ariaLabel,
42
+ block = false,
43
+ class: className,
44
+ }: Props = $props();
45
+
46
+ // =========================
47
+ // DERIVED
48
+ // =========================
49
+ let currentHref = $derived(current.replace(/\/$/, ""));
50
+ </script>
51
+
52
+ <!-- =========================== -->
53
+ <!-- MARKUP -->
54
+ <!-- =========================== -->
55
+ <!-- WHY scroll, not wrap: seven brands outrun a laptop pane. A clipped segment
56
+ must read as "more to the right", never as a broken row. -->
57
+ <nav
58
+ class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
59
+ aria-label={ariaLabel}
60
+ >
61
+ <SegmentedTrack fill={block}>
62
+ {#each items as item (item.href)}
63
+ {@const active = item.href.replace(/\/$/, "") === currentHref}
64
+ <a
65
+ href={item.href}
66
+ aria-current={active ? "page" : undefined}
67
+ title={item.label}
68
+ class={segmentedTriggerClass({ fill: block, active })}
69
+ >
70
+ <Segment
71
+ label={item.label}
72
+ count={item.count}
73
+ icon={item.icon}
74
+ image={item.image}
75
+ done={item.done}
76
+ badge={item.badge}
77
+ {active}
78
+ />
79
+ </a>
80
+ {/each}
81
+ </SegmentedTrack>
82
+ </nav>
@@ -0,0 +1,85 @@
1
+ <script lang="ts" generics="V extends string">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { CaretDown } from "phosphor-svelte";
6
+ import { Picker } from "../picker/index.js";
7
+ import { cn } from "@heyramzi/utils";
8
+ import { segmentedTriggerClass, type SegmentedVariant } from "./segmented.js";
9
+ import type { SegmentContent } from "./segment.svelte";
10
+
11
+ // =========================
12
+ // TYPES
13
+ // =========================
14
+ /** The last object in an overfull strip: one segment that opens the rest. */
15
+
16
+ // WHY the `Picker`: every "pick one of these" goes there, so keys and
17
+ // filtering match the command palette.
18
+
19
+ // WHY never active: `splitSegments` pulls the current item into a visible slot,
20
+ // so this never fights bits-ui's `data-state="open"`.
21
+ type Props = {
22
+ items: readonly (SegmentContent & { value: V })[];
23
+ onSelect: (value: V) => void;
24
+ /** What the segment says. Translate at the call site. */
25
+ label?: string;
26
+ fill?: boolean;
27
+ variant?: SegmentedVariant;
28
+ searchPlaceholder?: string;
29
+ emptyLabel?: string;
30
+ };
31
+
32
+ // =========================
33
+ // PROPS
34
+ // =========================
35
+ let {
36
+ items,
37
+ onSelect,
38
+ label = "More",
39
+ fill = false,
40
+ variant = "pill",
41
+ searchPlaceholder,
42
+ emptyLabel,
43
+ }: Props = $props();
44
+
45
+ // =========================
46
+ // DERIVED
47
+ // =========================
48
+
49
+ // A count rides as the row's hint, where the strip draws it as a pill.
50
+ let options = $derived(
51
+ items.map((item) => ({
52
+ value: item.value,
53
+ label: item.label,
54
+ hint: item.count != null ? String(item.count) : undefined,
55
+ icon: item.icon,
56
+ })),
57
+ );
58
+ </script>
59
+
60
+ <!-- =========================== -->
61
+ <!-- MARKUP -->
62
+ <!-- =========================== -->
63
+ <Picker
64
+ {options}
65
+ value={null}
66
+ onSelect={(value) => onSelect(value)}
67
+ triggerClass={cn(
68
+ segmentedTriggerClass({ variant, fill }),
69
+ "data-[state=open]:bg-foreground/5 data-[state=open]:text-foreground",
70
+ )}
71
+ triggerLabel={label}
72
+ align="end"
73
+ {searchPlaceholder}
74
+ {emptyLabel}
75
+ >
76
+ {#snippet trigger()}
77
+ {label}
78
+ <CaretDown
79
+ class={cn(
80
+ "shrink-0 transition-transform duration-150 group-data-[state=open]/segment:rotate-180",
81
+ "size-3.5",
82
+ )}
83
+ />
84
+ {/snippet}
85
+ </Picker>
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+ import { segmentedTrackClass, type SegmentedVariant } from "./segmented.js";
8
+ import { slidingPill } from "../../lib/sliding-pill.js";
9
+
10
+ // =========================
11
+ // TYPES
12
+ // =========================
13
+ type Props = {
14
+ /** Fill the row rather than sit at its natural width. */
15
+ fill?: boolean;
16
+ /** `pill`, a bordered well with a travelling highlight, or `underline`, a travelling rule. */
17
+ variant?: SegmentedVariant;
18
+ class?: string;
19
+ children: Snippet;
20
+ /**
21
+ * A last segment that isn't an item: today the overflow trigger. It's inside
22
+ * the track but outside the ARIA container, so a `tablist` owns only tabs.
23
+ */
24
+ after?: Snippet;
25
+ // What the ARIA shapes differ by: `role`/`aria-label` for the filter, nothing
26
+ // for the nav (its <nav> has it), bits-ui's wiring for tabs.
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ // =========================
31
+ // PROPS
32
+ // =========================
33
+ let {
34
+ fill = false,
35
+ variant = "pill",
36
+ class: className,
37
+ children,
38
+ after,
39
+ ...rest
40
+ }: Props = $props();
41
+ </script>
42
+
43
+ <!-- =========================== -->
44
+ <!-- MARKUP -->
45
+ <!-- =========================== -->
46
+ <!-- WHY its own component: the active segment is a travelling pill, and the
47
+ pill needs one element inside the track that no caller can forget. -->
48
+ <div
49
+ {...rest}
50
+ data-variant={variant}
51
+ class={cn("track relative", segmentedTrackClass({ variant, fill }), className)}
52
+ >
53
+ <!-- Renders at zero width until the attachment measures, so a server-rendered
54
+ strip shows the segment's own background and never an empty pill. -->
55
+ <span class="pill" aria-hidden="true" {@attach slidingPill}></span>
56
+ {@render children()}
57
+ {@render after?.()}
58
+ </div>
59
+
60
+ <!-- =========================== -->
61
+ <!-- STYLES -->
62
+ <!-- =========================== -->
63
+ <style>
64
+ /* Inset separators keep the strip one bordered object. */
65
+ .track :global(.ui-segment)::before {
66
+ content: "";
67
+ position: absolute;
68
+ top: 22%;
69
+ bottom: 22%;
70
+ left: 0;
71
+ width: 1px;
72
+ background-color: var(--color-border);
73
+ transition: opacity var(--duration-fast) var(--ease-smooth-out);
74
+ pointer-events: none;
75
+ }
76
+
77
+ .track :global(.pill + .ui-segment)::before,
78
+ .track :global([data-slot="tabs-list"] > .ui-segment:first-child)::before {
79
+ display: none;
80
+ }
81
+
82
+ /* The highlight and hover wash hide the seams on either side. */
83
+ .track :global(.ui-segment[data-state="active"])::before,
84
+ .track :global(.ui-segment[aria-current="page"])::before,
85
+ .track :global(.ui-segment[aria-pressed="true"])::before,
86
+ .track :global(.ui-segment[data-state="active"] + .ui-segment)::before,
87
+ .track :global(.ui-segment[aria-current="page"] + .ui-segment)::before,
88
+ .track :global(.ui-segment[aria-pressed="true"] + .ui-segment)::before,
89
+ .track :global([data-slot="tabs-list"]:has(> .ui-segment:last-child[data-state="active"]) + .ui-segment)::before,
90
+ .track :global(.ui-segment:hover)::before,
91
+ .track :global(.ui-segment:hover + .ui-segment)::before {
92
+ opacity: 0;
93
+ }
94
+
95
+ /* The label's colour lands with the pill, on the same fast step. */
96
+ .track :global(.ui-segment) {
97
+ transition:
98
+ background-color var(--duration-quick) var(--ease-smooth-out),
99
+ color var(--duration-fast) var(--ease-smooth-out),
100
+ transform var(--duration-micro) var(--ease-smooth-out);
101
+ }
102
+
103
+ /* A faint wash in the well says which segment the pointer would pick. */
104
+ @media (hover: hover) {
105
+ .track
106
+ :global(
107
+ .ui-segment:hover:not([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="open"])
108
+ ) {
109
+ background-color: color-mix(in oklch, var(--color-muted) 55%, transparent);
110
+ }
111
+ }
112
+
113
+ .pill {
114
+ position: absolute;
115
+ top: 2px;
116
+ bottom: 2px;
117
+ left: 0;
118
+ z-index: 0;
119
+ width: 0;
120
+ opacity: 0;
121
+ /* Concentric with the border and the 2px inset. */
122
+ border-radius: calc(var(--radius-lg) - 3px);
123
+ background-color: var(--color-muted);
124
+ box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-foreground) 6%, transparent);
125
+ transform: translateX(0);
126
+ /* transitions-dev `16-tabs-sliding`: the fast step, 250ms. */
127
+ transition:
128
+ transform var(--duration-fast) var(--ease-smooth-out),
129
+ width var(--duration-fast) var(--ease-smooth-out),
130
+ opacity var(--duration-quick) var(--ease-smooth-out);
131
+ will-change: transform, width;
132
+ pointer-events: none;
133
+ }
134
+
135
+ /* Before the attachment measures, the active segment draws the pill itself,
136
+ so a server-rendered strip is right before any JS arrives. */
137
+ .track:not([data-pill="on"]) :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
138
+ background-color: var(--color-muted);
139
+ box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-foreground) 6%, transparent);
140
+ }
141
+
142
+ /* The underline: no seams, no wash, and the pill becomes a 2px rule on the
143
+ hairline. `--segmented-rule` lets a brand colour it from a class. */
144
+ .track[data-variant="underline"] :global(.ui-segment)::before {
145
+ display: none;
146
+ }
147
+
148
+ @media (hover: hover) {
149
+ .track[data-variant="underline"]
150
+ :global(
151
+ .ui-segment:hover:not([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="open"])
152
+ ) {
153
+ background-color: transparent;
154
+ }
155
+ }
156
+
157
+ .track[data-variant="underline"] .pill {
158
+ top: auto;
159
+ bottom: -1px;
160
+ height: 2px;
161
+ border-radius: 1px;
162
+ background-color: var(--segmented-rule, var(--color-foreground));
163
+ box-shadow: none;
164
+ }
165
+
166
+ .track[data-variant="underline"]:not([data-pill="on"])
167
+ :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
168
+ background-color: transparent;
169
+ box-shadow: inset 0 -2px 0 var(--segmented-rule, var(--color-foreground));
170
+ }
171
+
172
+ @media (prefers-reduced-motion: reduce) {
173
+ .pill {
174
+ transition: opacity var(--duration-quick) var(--ease-smooth-out);
175
+ }
176
+
177
+ .track :global(.ui-segment) {
178
+ transition: none;
179
+ }
180
+ }
181
+ </style>
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { segmentedTrackClass } from "./segmented.js";
3
+
4
+ describe("segmentedTrackClass", () => {
5
+ // Every segmented control shares one height, whatever its look.
6
+ it("holds one height across variants", () => {
7
+ for (const variant of ["pill", "underline"] as const) {
8
+ const classes = segmentedTrackClass({ variant }).split(" ");
9
+ expect(classes).toContain("h-7");
10
+ expect(classes).toContain("any-pointer-coarse:h-10");
11
+ }
12
+ });
13
+
14
+ it("draws the pill as a bordered well and the underline as a hairline", () => {
15
+ expect(segmentedTrackClass({ variant: "pill" })).toContain("rounded-lg");
16
+ const underline = segmentedTrackClass({ variant: "underline" });
17
+ expect(underline).toContain("border-b");
18
+ expect(underline).not.toContain("rounded-lg");
19
+ });
20
+ });
@@ -0,0 +1,80 @@
1
+ import { tv } from "tailwind-variants";
2
+ import { pageGutterClass } from "../../lib/layout.js";
3
+
4
+ /**
5
+ * The one look for a segmented control: the track and trigger here, what a
6
+ * segment says in `segment.svelte`.
7
+ */
8
+
9
+ // WHY a recipe, not one component: tabs are a `tablist`, filters a `group` of
10
+ // `aria-pressed` toggles, page links a `nav`. Only the ARIA shape may differ.
11
+
12
+ // The look used to drift three ways, one with an at-rest shadow. All three read from here now.
13
+ export const segmentedTrackClass = tv({
14
+ // WHY the height lives on the track: it carries the inset, so an `h-7` trigger
15
+ // drew 30px beside a 28px button. One toolbar row came out at five heights.
16
+
17
+ // WHY no size variant: `sm` drew 24px, and every tab control must share one height.
18
+ base: "h-7 any-pointer-coarse:h-10 shrink-0 items-stretch",
19
+ variants: {
20
+ // `pill`: a bordered well with a travelling highlight. `underline`: bare
21
+ // labels over one hairline, the active one marked by a rule that travels.
22
+ variant: {
23
+ pill: "overflow-hidden rounded-lg border border-border p-0.5",
24
+ underline: "gap-5 border-b border-border",
25
+ },
26
+ // Fill the row rather than sit at its natural width. For a narrow rail.
27
+ fill: { true: "flex w-full", false: "inline-flex w-fit" },
28
+ },
29
+ defaultVariants: { variant: "pill", fill: false },
30
+ });
31
+
32
+ export type SegmentedVariant = "pill" | "underline";
33
+
34
+ export const segmentedTriggerClass = tv({
35
+ base: [
36
+ // `t-press` is the kit's one press: colour, shadow and a pixel of travel.
37
+
38
+ // `relative z-[1]` keeps the label above the travelling pill, or the pill
39
+ // paints over the word it highlights.
40
+
41
+ // `ui-segment` isn't a style. `segmented-track.svelte` matches on it to time
42
+ // the label colour with the pill.
43
+ "ui-segment group/segment t-press relative z-[1] inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium select-none",
44
+ "text-muted-foreground hover:text-foreground",
45
+ "disabled:pointer-events-none disabled:opacity-50",
46
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
47
+ // WHY an inset focus ring: the track clips to its rounded corners, so an
48
+ // outside ring was cut in half on the end segments.
49
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none",
50
+ // WHY no height on the trigger: an icon makes a segment taller than a word.
51
+ // `h-full` against the track's `items-stretch` keeps every segment the same box.
52
+ "h-full text-xs",
53
+ ],
54
+ variants: {
55
+ // The pill is concentric with the track's border and its 2px inset. The
56
+ // underline has no well to be concentric with, so no padding either: the
57
+ // rule under a label is as wide as the label.
58
+ variant: {
59
+ pill: "rounded-[calc(var(--radius-lg)-3px)] px-2.5 any-pointer-coarse:px-3.5",
60
+ underline: "rounded-sm px-0",
61
+ },
62
+ // WHY `min-w-fit flex-auto`, not equal fractions: fractions cut "Approved" to
63
+ // "Appro…" in a 280px rail. Each segment claims its label and shares the slack.
64
+ fill: { true: "min-w-fit flex-auto", false: "" },
65
+ active: { true: "text-foreground", false: "" },
66
+ },
67
+ defaultVariants: { variant: "pill", fill: false, active: false },
68
+ });
69
+
70
+ /**
71
+ * The row a segmented strip sits in, and the one recipe for a navigation row
72
+ * under the app header. Written per caller it produced two different left
73
+ * edges.
74
+ */
75
+ // WHY no bottom hairline: with a section strip and a page toolbar stacked it
76
+ // drew two rules for one boundary. The gap is the boundary.
77
+ export const segmentedRowClass = `flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 py-2 ${pageGutterClass}`;
78
+
79
+ /** The active style, for a strip whose state lives in a `data-active` attribute. */
80
+ export const segmentedActiveClass = "data-active:text-foreground";
@@ -0,0 +1,9 @@
1
+ import Root from "./star-rating-input.svelte";
2
+
3
+ export type { StarRatingSize } from "./star-rating-input.svelte";
4
+
5
+ export {
6
+ Root,
7
+ //
8
+ Root as StarRatingInput,
9
+ };