@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,206 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { setContext } from "svelte";
7
+ import { CaretLeft } from "phosphor-svelte";
8
+ import { cn } from "@heyramzi/utils";
9
+ import { Button } from "../../shadcn/button/index.js";
10
+ import { MASTER_DETAIL_PANE, type MasterDetailPane } from "./queue-rail.js";
11
+
12
+ // =========================
13
+ // TYPES
14
+ // =========================
15
+ type Props = {
16
+ // The list rail (an <aside>) and the content pane. Pages style both; this
17
+ // shell owns the two columns and the min-h-0/min-w-0 scroll containment.
18
+ rail: Snippet;
19
+ detail: Snippet;
20
+ // A full-width strip above both columns for facts to read before acting,
21
+ // like a posting cap. Pages wrapping the shell to add one broke the padding.
22
+ banner?: Snippet;
23
+ // WHY: a CSS grid track for the rail column ("16rem", "minmax(280px, 340px)").
24
+ // Queue pages pass `queueRailWidth` rather than a literal.
25
+ railWidth?: string;
26
+ // Stack to one column below this. "lg" because rail and detail can't both be
27
+ // read under 1024px. null only for a rail narrow enough for a phone.
28
+ stackBelow?: "lg" | "xl" | null;
29
+ // WHY: the phone back button says where it goes back to. "Back" alone is the
30
+ // browser's word and the reader has to remember what they left.
31
+ backLabel?: string;
32
+ // Bindable so a url naming a record opens on it after a refresh or a pasted
33
+ // link. The page sets it. Ignored above the stack breakpoint.
34
+ pane?: "rail" | "detail";
35
+ gap?: boolean;
36
+ pad?: boolean;
37
+ railClass?: string;
38
+ detailClass?: string;
39
+ class?: string;
40
+ };
41
+
42
+ // =========================
43
+ // PROPS
44
+ // =========================
45
+ let {
46
+ rail,
47
+ detail,
48
+ banner,
49
+ railWidth = "minmax(260px, 320px)",
50
+ stackBelow = "lg",
51
+ backLabel = "Back to the list",
52
+ // The rail wins by default: an unpicked detail is an empty state or a
53
+ // guessed row, both worse than the list.
54
+ pane = $bindable("rail"),
55
+ gap = true,
56
+ pad = true,
57
+ railClass,
58
+ detailClass,
59
+ class: className,
60
+ }: Props = $props();
61
+
62
+ // =========================
63
+ // DERIVED
64
+ // =========================
65
+ // One pane at a time when stacked. Two rows put a 34svh list scroller on top of
66
+ // a record scroller, and neither was readable.
67
+ let gridClass = $derived(
68
+ stackBelow === "xl"
69
+ ? "grid-cols-1 grid-rows-[minmax(0,1fr)] xl:grid-rows-1 xl:[grid-template-columns:var(--md-cols)]"
70
+ : stackBelow === "lg"
71
+ ? "grid-cols-1 grid-rows-[minmax(0,1fr)] lg:grid-rows-1 lg:[grid-template-columns:var(--md-cols)]"
72
+ : "[grid-template-columns:var(--md-cols)]",
73
+ );
74
+ // Hidden while stacked; back to normal above the breakpoint, where both panes show.
75
+ let hideWhileStacked = $derived(stackBelow === "xl" ? "max-xl:hidden" : "max-lg:hidden");
76
+ // Drawn only while stacked. The back bar has no job beside a visible rail.
77
+ let stackedOnly = $derived(stackBelow === "xl" ? "xl:hidden" : "lg:hidden");
78
+
79
+ // =========================
80
+ // FUNCTIONS
81
+ // =========================
82
+ // `RailRow` calls this when a record is picked. Above the breakpoint the classes
83
+ // that read `pane` are all prefixed away, so the flag changes nothing there.
84
+ setContext<MasterDetailPane>(MASTER_DETAIL_PANE, {
85
+ open: () => {
86
+ if (stackBelow) pane = "detail";
87
+ },
88
+ });
89
+ </script>
90
+
91
+ <!-- =========================== -->
92
+ <!-- MARKUP -->
93
+ <!-- =========================== -->
94
+ {#snippet columns(inBanner: boolean)}
95
+ <div
96
+ class={cn(
97
+ "md-cols grid min-h-0",
98
+ inBanner ? "flex-1" : "h-full",
99
+ gap && "gap-4",
100
+ pad && !inBanner && "p-4",
101
+ gridClass,
102
+ className,
103
+ )}
104
+ style="--md-cols: {railWidth} minmax(0, 1fr)"
105
+ data-pane={stackBelow ? pane : undefined}
106
+ data-stack={stackBelow ?? undefined}
107
+ >
108
+ <aside
109
+ class={cn(
110
+ "md-rail min-h-0",
111
+ railClass,
112
+ stackBelow && pane === "detail" && hideWhileStacked,
113
+ )}
114
+ >
115
+ {@render rail()}
116
+ </aside>
117
+ <div
118
+ class={cn(
119
+ "md-detail flex min-h-0 min-w-0 flex-col",
120
+ stackBelow && pane === "rail" && hideWhileStacked,
121
+ )}
122
+ >
123
+ {#if stackBelow}
124
+ <!-- WHY a button and not a link: nothing navigated, so there is no url to
125
+ go back to and the browser's own back would leave the page. -->
126
+ <Button
127
+ variant="ghost"
128
+ size="sm"
129
+ class={cn("-mx-1 mb-2 self-start px-1 text-muted-foreground", stackedOnly)}
130
+ onclick={() => (pane = "rail")}
131
+ >
132
+ <CaretLeft size={16} weight="bold" />
133
+ {backLabel}
134
+ </Button>
135
+ {/if}
136
+ <div class={cn("min-h-0 min-w-0 flex-1", detailClass)}>
137
+ {@render detail()}
138
+ </div>
139
+ </div>
140
+ </div>
141
+ {/snippet}
142
+
143
+ {#if banner}
144
+ <div class={cn("flex h-full min-h-0 flex-col", gap && "gap-4", pad && "p-4")}>
145
+ {@render banner()}
146
+ {@render columns(true)}
147
+ </div>
148
+ {:else}
149
+ {@render columns(false)}
150
+ {/if}
151
+
152
+ <!-- =========================== -->
153
+ <!-- STYLE -->
154
+ <!-- =========================== -->
155
+ <style>
156
+ /* The stacked list-to-record swap. The incoming pane moves: forward from the
157
+ right, back from the left. It runs when `data-pane` flips. */
158
+ @media (max-width: 1023px) {
159
+ .md-cols[data-stack="lg"][data-pane="detail"] > .md-detail {
160
+ animation: md-in-forward var(--duration-fast) var(--ease-smooth-out) both;
161
+ }
162
+
163
+ .md-cols[data-stack="lg"][data-pane="rail"] > .md-rail {
164
+ animation: md-in-back var(--duration-fast) var(--ease-smooth-out) both;
165
+ }
166
+ }
167
+
168
+ @media (max-width: 1279px) {
169
+ .md-cols[data-stack="xl"][data-pane="detail"] > .md-detail {
170
+ animation: md-in-forward var(--duration-fast) var(--ease-smooth-out) both;
171
+ }
172
+
173
+ .md-cols[data-stack="xl"][data-pane="rail"] > .md-rail {
174
+ animation: md-in-back var(--duration-fast) var(--ease-smooth-out) both;
175
+ }
176
+ }
177
+
178
+ @keyframes md-in-forward {
179
+ from {
180
+ opacity: 0;
181
+ transform: translateX(var(--distance-large));
182
+ }
183
+ to {
184
+ opacity: 1;
185
+ transform: none;
186
+ }
187
+ }
188
+
189
+ @keyframes md-in-back {
190
+ from {
191
+ opacity: 0;
192
+ transform: translateX(calc(var(--distance-large) * -1));
193
+ }
194
+ to {
195
+ opacity: 1;
196
+ transform: none;
197
+ }
198
+ }
199
+
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .md-cols > .md-rail,
202
+ .md-cols > .md-detail {
203
+ animation-duration: 1ms;
204
+ }
205
+ }
206
+ </style>
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vite-plus/test";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const shell = readFileSync(resolve(here, "master-detail.svelte"), "utf8");
8
+ const row = readFileSync(resolve(here, "rail-row.svelte"), "utf8");
9
+
10
+ describe("master detail on a phone", () => {
11
+ it("shows one pane at a time below the stack breakpoint", () => {
12
+ // Two rows put a 34svh rail scroller above a second one. One row, one
13
+ // pane, hidden only while stacked so desktop stays untouched.
14
+ expect(shell).toContain("grid-rows-[minmax(0,1fr)] lg:grid-rows-1");
15
+ expect(shell).toContain("grid-rows-[minmax(0,1fr)] xl:grid-rows-1");
16
+ expect(shell).not.toContain("max-h-[34svh]");
17
+ expect(shell).toContain('stackBelow === "xl" ? "max-xl:hidden" : "max-lg:hidden"');
18
+ expect(shell).toContain('stackBelow && pane === "detail" && hideWhileStacked');
19
+ expect(shell).toContain('stackBelow && pane === "rail" && hideWhileStacked');
20
+ });
21
+
22
+ it("draws a way back to the list, and only while stacked", () => {
23
+ expect(shell).toContain('stackBelow === "xl" ? "xl:hidden" : "lg:hidden"');
24
+ expect(shell).toContain('onclick={() => (pane = "rail")}');
25
+ expect(shell).toContain("{backLabel}");
26
+ });
27
+
28
+ it("lets a page open on the record its url names", () => {
29
+ expect(shell).toContain('pane = $bindable("rail")');
30
+ });
31
+
32
+ it("moves to the record when a rail row is picked", () => {
33
+ expect(shell).toContain("setContext<MasterDetailPane>(MASTER_DETAIL_PANE");
34
+ expect(row).toContain("getContext<MasterDetailPane | undefined>(MASTER_DETAIL_PANE)");
35
+ expect(row).toContain("pane?.open()");
36
+ });
37
+ });
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { Warning, Info, CheckCircle, WarningCircle } from "phosphor-svelte";
7
+ import { cn } from "@heyramzi/utils";
8
+ import { Alert } from "../../shadcn/alert/index.js";
9
+ import { TONE_CLASS, type Tone as SeverityTone } from "../../lib/tone.js";
10
+ import type { PhosphorIcon } from "../../lib/icon-types.js";
11
+
12
+ // =========================
13
+ // TYPES
14
+ // =========================
15
+ // A strip that stops the reader is a severity. Neutral and accent are chip tones.
16
+ type Tone = Extract<SeverityTone, "destructive" | "warning" | "info" | "success">;
17
+
18
+ interface Props {
19
+ // The "look at this before you act" strip. Hand-rolled ones used raw amber
20
+ // and sky, so one severity showed two colours one route apart.
21
+ tone?: Tone;
22
+ icon?: PhosphorIcon;
23
+ // The trailing control: a retry, a jump to the thing that needs fixing.
24
+ actions?: Snippet;
25
+ children: Snippet;
26
+ class?: string;
27
+ }
28
+
29
+ const TONE_ICON: Record<Tone, PhosphorIcon> = {
30
+ destructive: Warning,
31
+ warning: WarningCircle,
32
+ info: Info,
33
+ success: CheckCircle,
34
+ };
35
+
36
+ // =========================
37
+ // PROPS
38
+ // =========================
39
+ let { tone = "destructive", icon, actions, children, class: className }: Props = $props();
40
+
41
+ // =========================
42
+ // DERIVED
43
+ // =========================
44
+ let Icon = $derived(icon ?? TONE_ICON[tone]);
45
+ </script>
46
+
47
+ <!-- =========================== -->
48
+ <!-- MARKUP -->
49
+ <!-- =========================== -->
50
+ <!-- WHY `status` for the calm tones: info and success shouldn't interrupt a
51
+ screen reader the way a failure does. -->
52
+ <Alert
53
+ variant={tone === "destructive" ? "destructive" : "default"}
54
+ role={tone === "destructive" || tone === "warning" ? "alert" : "status"}
55
+ class={cn(
56
+ "flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl [&>svg]:translate-y-0",
57
+ TONE_CLASS[tone],
58
+ className,
59
+ )}
60
+ >
61
+ <Icon class="size-4 shrink-0" aria-hidden="true" />
62
+ <span class="min-w-0 flex-1 font-medium">{@render children()}</span>
63
+ {#if actions}
64
+ <div class="flex shrink-0 items-center gap-2">
65
+ {@render actions()}
66
+ </div>
67
+ {/if}
68
+ </Alert>
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { CaretLeft, CaretRight } from "phosphor-svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+ import { Button } from "../../shadcn/button/index.js";
8
+ import { stepTo } from "./step.js";
9
+
10
+ // =========================
11
+ // TYPES
12
+ // =========================
13
+ type Props = {
14
+ /** Ids in the order the list shows them. */
15
+ ids: readonly string[];
16
+ current: string | null | undefined;
17
+ onselect: (id: string) => void;
18
+ previousLabel?: string;
19
+ nextLabel?: string;
20
+ /** Off when two steppers share a page, so one key press moves one list. */
21
+ keys?: boolean;
22
+ class?: string;
23
+ };
24
+
25
+ // =========================
26
+ // PROPS
27
+ // =========================
28
+ let {
29
+ ids,
30
+ current,
31
+ onselect,
32
+ previousLabel = "Previous",
33
+ nextLabel = "Next",
34
+ keys = true,
35
+ class: className,
36
+ }: Props = $props();
37
+
38
+ // =========================
39
+ // DERIVED
40
+ // =========================
41
+ let index = $derived(current ? ids.indexOf(current) : -1);
42
+ let prev = $derived(stepTo(ids, current, -1));
43
+ let next = $derived(stepTo(ids, current, 1));
44
+
45
+ // =========================
46
+ // HANDLERS
47
+ // =========================
48
+ function isTyping(event: KeyboardEvent): boolean {
49
+ const target = event.target as HTMLElement | null;
50
+ return Boolean(target?.closest("input, textarea, select, [contenteditable='true']"));
51
+ }
52
+
53
+ function ignored(event: KeyboardEvent): boolean {
54
+ const modified = event.metaKey || event.ctrlKey || event.altKey;
55
+ return !keys || modified || event.defaultPrevented || isTyping(event);
56
+ }
57
+
58
+ // K and J, as in Gmail: the arrows already belong to slide viewers and text fields.
59
+ function targetFor(key: string): string | null {
60
+ if (key === "k") return prev;
61
+ if (key === "j") return next;
62
+ return null;
63
+ }
64
+
65
+ function onkeydown(event: KeyboardEvent) {
66
+ if (ignored(event)) return;
67
+ const id = targetFor(event.key);
68
+ if (!id) return;
69
+ event.preventDefault();
70
+ onselect(id);
71
+ }
72
+ </script>
73
+
74
+ <svelte:window {onkeydown} />
75
+
76
+ {#if index >= 0 && ids.length > 1}
77
+ <div class={cn("flex items-center gap-0.5 text-muted-foreground", className)}>
78
+ <Button
79
+ variant="ghost"
80
+ size="sm"
81
+ icon
82
+ class="text-muted-foreground"
83
+ disabled={!prev}
84
+ onclick={() => prev && onselect(prev)}
85
+ aria-label={previousLabel}
86
+ title={`${previousLabel} (K)`}
87
+ >
88
+ <CaretLeft size={15} />
89
+ </Button>
90
+ <span class="min-w-9 text-center text-xs tabular-nums">{index + 1}/{ids.length}</span>
91
+ <Button
92
+ variant="ghost"
93
+ size="sm"
94
+ icon
95
+ class="text-muted-foreground"
96
+ disabled={!next}
97
+ onclick={() => next && onselect(next)}
98
+ aria-label={nextLabel}
99
+ title={`${nextLabel} (J)`}
100
+ >
101
+ <CaretRight size={15} />
102
+ </Button>
103
+ </div>
104
+ {/if}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The rail surface of a queue page, passed to `MasterDetail`'s `railClass`.
3
+ * A class, not a wrapper: the shell's `<aside>` already owns the scroll height.
4
+ */
5
+ // Copies of this string drifted to `rounded-lg`, the control radius, not the container one.
6
+ export const queueRailClass =
7
+ "flex flex-col overflow-hidden rounded-xl border border-border bg-card";
8
+
9
+ /**
10
+ * The rail column track, passed to `MasterDetail`'s `railWidth`. One page ran
11
+ * 20px wider, so the detail pane jumped between pages.
12
+ */
13
+ // 280px fits a two-line thread title. Past 340px the list competes with the record.
14
+ export const queueRailWidth = "minmax(280px, 340px)";
15
+
16
+ /**
17
+ * Context key for the pane `MasterDetail` shows on a phone. Picking a row moves
18
+ * to the record, so `RailRow` reads this instead of each page passing a callback.
19
+ */
20
+ export const MASTER_DETAIL_PANE = Symbol("master-detail-pane");
21
+
22
+ export type MasterDetailPane = {
23
+ /** Show the detail pane. No-op above the stack breakpoint, where both are visible. */
24
+ open: () => void;
25
+ };
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+
8
+ // =========================
9
+ // TYPES
10
+ // =========================
11
+ type Props = {
12
+ // The scroller under `RailHead`. `min-h-0` makes a flex child scroll instead
13
+ // of growing its parent, and it's the line hand-rolled rails forgot.
14
+ children: Snippet;
15
+ class?: string;
16
+ };
17
+
18
+ // =========================
19
+ // PROPS
20
+ // =========================
21
+ let { children, class: className }: Props = $props();
22
+ </script>
23
+
24
+ <!-- =========================== -->
25
+ <!-- MARKUP -->
26
+ <!-- =========================== -->
27
+ <div class={cn("min-h-0 flex-1 overflow-y-auto", className)}>
28
+ {@render children()}
29
+ </div>
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { cn } from "@heyramzi/utils";
6
+ import * as Empty from "../../shadcn/empty/index.js";
7
+ import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ // Not `EmptyState`, which has a 48px icon and an action. This says a filter
14
+ // came back empty, in one sentence that fits a 280px rail.
15
+ icon?: PhosphorIcon;
16
+ iconWeight?: IconWeight;
17
+ iconClass?: string;
18
+ title: string;
19
+ hint?: string;
20
+ class?: string;
21
+ }
22
+
23
+ // =========================
24
+ // PROPS
25
+ // =========================
26
+ let {
27
+ icon: Icon,
28
+ iconWeight,
29
+ iconClass,
30
+ title,
31
+ hint,
32
+ class: className,
33
+ }: Props = $props();
34
+ </script>
35
+
36
+ <!-- =========================== -->
37
+ <!-- MARKUP -->
38
+ <!-- =========================== -->
39
+ <Empty.Root class={cn("h-full gap-2 px-6 py-12", className)}>
40
+ {#if Icon}
41
+ <Icon
42
+ weight={iconWeight}
43
+ class={cn("size-6", iconClass ?? "text-muted-foreground/40")}
44
+ aria-hidden="true"
45
+ />
46
+ {/if}
47
+ <Empty.Title>{title}</Empty.Title>
48
+ {#if hint}
49
+ <Empty.Description class="max-w-56 text-xs leading-relaxed">{hint}</Empty.Description>
50
+ {/if}
51
+ </Empty.Root>
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import SectionLabel from "./section-label.svelte";
7
+ import { cn } from "@heyramzi/utils";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ type Props = {
13
+ // The fixed top of a queue rail: label, filter, search. A fixed height, so
14
+ // rows start on the same line on every queue page.
15
+ label?: string;
16
+ // A low-emphasis control beside the label (compose, refresh).
17
+ action?: Snippet;
18
+ // The filter, the search, whatever narrows the list. Optional: a rail of four
19
+ // records has nothing to narrow, and an empty strip under the label is chrome.
20
+ children?: Snippet;
21
+ class?: string;
22
+ };
23
+
24
+ // =========================
25
+ // PROPS
26
+ // =========================
27
+ let { label, action, children, class: className }: Props = $props();
28
+ </script>
29
+
30
+ <!-- =========================== -->
31
+ <!-- MARKUP -->
32
+ <!-- =========================== -->
33
+ <div class={cn("shrink-0 space-y-2 border-b border-border px-3 py-2.5", className)}>
34
+ {#if label || action}
35
+ <div class="flex min-h-7 items-center justify-between gap-2 pl-1">
36
+ {#if label}
37
+ <SectionLabel>{label}</SectionLabel>
38
+ {:else}
39
+ <span></span>
40
+ {/if}
41
+ {#if action}
42
+ {@render action()}
43
+ {/if}
44
+ </div>
45
+ {/if}
46
+ {#if children}
47
+ {@render children()}
48
+ {/if}
49
+ </div>