@heyramzi/ui 3.1.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/components/list-page/toolbar-density.js +20 -1
  2. package/dist/lib/sliding-pill.js +2 -2
  3. package/dist/record/measure-clamp.js +23 -0
  4. package/dist/shadcn/button-group/index.js +6 -0
  5. package/dist/shadcn/checkbox/checkbox-box.js +3 -0
  6. package/dist/shadcn/drawer/index.js +14 -0
  7. package/dist/shadcn/input-group/index.js +9 -0
  8. package/dist/shadcn/item/index.js +13 -0
  9. package/dist/shadcn/native-select/index.js +6 -0
  10. package/dist/shadcn/pagination/index.js +12 -0
  11. package/package.json +7 -2
  12. package/scripts/sync-shadcn.mjs +0 -6
  13. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  14. package/src/canvas/pen/PenOverlay.svelte +1 -30
  15. package/src/canvas/screen/PartView.svelte +13 -8
  16. package/src/canvas/screen/ScreenBody.svelte +18 -11
  17. package/src/canvas/ui/BarTooltip.svelte +46 -0
  18. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  19. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  20. package/src/canvas/ui/StylePanel.svelte +179 -102
  21. package/src/canvas/ui/canvas-ui.css +12 -76
  22. package/src/charts/dashboard/data-table.svelte +80 -80
  23. package/src/charts/dashboard/hint.svelte +34 -0
  24. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  25. package/src/charts/dashboard/rating-chip.svelte +4 -3
  26. package/src/charts/legend.css +0 -8
  27. package/src/charts/legend.svelte +26 -30
  28. package/src/components/copy/copy-button.svelte +10 -2
  29. package/src/components/copy/copy-button.test.ts +7 -0
  30. package/src/components/data-table/table-skeleton.svelte +15 -7
  31. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  32. package/src/components/docs/on-this-page.svelte +2 -2
  33. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  34. package/src/components/list-page/filter-item.svelte +20 -7
  35. package/src/components/list-page/filter-popover.svelte +57 -17
  36. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  37. package/src/components/list-page/grouped-list.svelte +41 -23
  38. package/src/components/list-page/list-page-shell.svelte +155 -69
  39. package/src/components/list-page/list-page-shell.svelte.d.ts +6 -1
  40. package/src/components/list-page/toolbar-density.ts +24 -1
  41. package/src/components/picker/picker.svelte +8 -3
  42. package/src/components/picker/picker.svelte.d.ts +2 -1
  43. package/src/components/segmented/segmented-filter.svelte +19 -10
  44. package/src/components/segmented/segmented-track.svelte +11 -3
  45. package/src/components/table-view/table-view.svelte +22 -6
  46. package/src/components/tags-input/tags-input.svelte +94 -77
  47. package/src/components/toolbar/toolbar.svelte +2 -1
  48. package/src/components/toolbar/toolbar.test.ts +5 -4
  49. package/src/components/work-parts/group-head.svelte +28 -9
  50. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  51. package/src/editor/editor-toolbar.svelte +223 -193
  52. package/src/editor/editor.svelte +56 -38
  53. package/src/editor/image-toolbar.svelte +99 -37
  54. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  55. package/src/editor/url-dialog.svelte +96 -0
  56. package/src/editor/url-dialog.svelte.d.ts +15 -0
  57. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  58. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  59. package/src/inbox/thread-inbox.svelte +9 -1
  60. package/src/lib/live-region.svelte +12 -4
  61. package/src/lib/live-region.svelte.d.ts +5 -1
  62. package/src/lib/sliding-pill.ts +3 -2
  63. package/src/record/info-list.svelte +33 -59
  64. package/src/record/measure-clamp.ts +31 -0
  65. package/src/record/record-shell.svelte +38 -39
  66. package/src/record/record-timeline.svelte +10 -5
  67. package/src/record/steps-rail.svelte +50 -36
  68. package/src/record/where-we-are.svelte +5 -19
  69. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  70. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  71. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  72. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  73. package/src/shadcn/button-group/button-group.svelte +46 -0
  74. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  75. package/src/shadcn/button-group/index.ts +15 -0
  76. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  77. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  78. package/src/shadcn/drawer/drawer-close.svelte +7 -0
  79. package/src/shadcn/drawer/drawer-close.svelte.d.ts +4 -0
  80. package/src/shadcn/drawer/drawer-content.svelte +36 -0
  81. package/src/shadcn/drawer/drawer-content.svelte.d.ts +10 -0
  82. package/src/shadcn/drawer/drawer-description.svelte +17 -0
  83. package/src/shadcn/drawer/drawer-description.svelte.d.ts +4 -0
  84. package/src/shadcn/drawer/drawer-footer.svelte +20 -0
  85. package/src/shadcn/drawer/drawer-footer.svelte.d.ts +5 -0
  86. package/src/shadcn/drawer/drawer-header.svelte +23 -0
  87. package/src/shadcn/drawer/drawer-header.svelte.d.ts +5 -0
  88. package/src/shadcn/drawer/drawer-nested.svelte +12 -0
  89. package/src/shadcn/drawer/drawer-nested.svelte.d.ts +3 -0
  90. package/src/shadcn/drawer/drawer-overlay.svelte +20 -0
  91. package/src/shadcn/drawer/drawer-overlay.svelte.d.ts +4 -0
  92. package/src/shadcn/drawer/drawer-portal.svelte +7 -0
  93. package/src/shadcn/drawer/drawer-portal.svelte.d.ts +3 -0
  94. package/src/shadcn/drawer/drawer-title.svelte +17 -0
  95. package/src/shadcn/drawer/drawer-title.svelte.d.ts +4 -0
  96. package/src/shadcn/drawer/drawer-trigger.svelte +7 -0
  97. package/src/shadcn/drawer/drawer-trigger.svelte.d.ts +4 -0
  98. package/src/shadcn/drawer/drawer.svelte +12 -0
  99. package/src/shadcn/drawer/drawer.svelte.d.ts +3 -0
  100. package/src/shadcn/drawer/index.ts +38 -0
  101. package/src/shadcn/input-group/index.ts +22 -0
  102. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  103. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  104. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  105. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  106. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  107. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  108. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  109. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  110. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  111. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  112. package/src/shadcn/input-group/input-group.svelte +24 -0
  113. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  114. package/src/shadcn/item/index.ts +34 -0
  115. package/src/shadcn/item/item-actions.svelte +20 -0
  116. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  117. package/src/shadcn/item/item-content.svelte +23 -0
  118. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  119. package/src/shadcn/item/item-description.svelte +23 -0
  120. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  121. package/src/shadcn/item/item-footer.svelte +20 -0
  122. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  123. package/src/shadcn/item/item-group.svelte +21 -0
  124. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  125. package/src/shadcn/item/item-header.svelte +20 -0
  126. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  127. package/src/shadcn/item/item-media.svelte +42 -0
  128. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  129. package/src/shadcn/item/item-separator.svelte +19 -0
  130. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  131. package/src/shadcn/item/item-title.svelte +20 -0
  132. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  133. package/src/shadcn/item/item.svelte +61 -0
  134. package/src/shadcn/item/item.svelte.d.ts +28 -0
  135. package/src/shadcn/native-select/index.ts +13 -0
  136. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  137. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  138. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  139. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  140. package/src/shadcn/native-select/native-select.svelte +45 -0
  141. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  142. package/src/shadcn/pagination/index.ts +31 -0
  143. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  144. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  145. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  146. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  147. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  148. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  149. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  150. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  151. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  152. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  153. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  154. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  155. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  156. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  157. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  158. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  159. package/src/shadcn/pagination/pagination.svelte +27 -0
  160. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  161. package/src/slides/Controls.svelte +39 -36
  162. package/src/slides/Deck.svelte +11 -8
  163. package/src/thread/thread.svelte +15 -8
  164. package/src/work/detail/task-detail.svelte +1 -1
  165. package/src/work/gantt/gantt-task-row.svelte +3 -6
  166. package/src/work/inbox/inbox-card.svelte +7 -5
  167. package/src/work/inbox/inbox-trace.svelte +14 -10
  168. package/src/work/kanban-board.svelte +40 -25
  169. package/src/work/kanban-hidden-rail.svelte +67 -65
  170. package/src/work/task-list.svelte +145 -124
  171. package/src/work/task-list.svelte.test.ts +5 -2
  172. package/src/work/time/time-tracker.svelte +35 -10
@@ -2,7 +2,9 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
- import type { Snippet } from "svelte";
5
+ import { flushSync, tick, untrack, type Snippet } from "svelte";
6
+ import { MagnifyingGlass, X } from "phosphor-svelte";
7
+ import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
6
8
  import EmptyState from "./empty-state.svelte";
7
9
  import { cn } from "../../lib/utils.js";
8
10
  import { pageGutterClass } from "../../lib/layout.js";
@@ -40,6 +42,11 @@
40
42
  content?: Snippet;
41
43
  empty?: Snippet;
42
44
  extra?: Snippet;
45
+ /** The folded search icon's name, on a toolbar too narrow for the field.
46
+ * Translate at the call site. */
47
+ searchLabel?: string;
48
+ /** The close button's name once the folded search is open. */
49
+ closeSearchLabel?: string;
43
50
  }
44
51
 
45
52
  // =========================
@@ -61,6 +68,8 @@
61
68
  content,
62
69
  empty,
63
70
  extra,
71
+ searchLabel = "Search",
72
+ closeSearchLabel = "Close search",
64
73
  }: Props = $props();
65
74
 
66
75
  // =========================
@@ -69,16 +78,33 @@
69
78
  // Null off an app shell, where the toolbar just has no section strip to draw.
70
79
  const section = getSectionHost();
71
80
  let row = $state<HTMLElement>();
72
- let tabsBox = $state<HTMLElement>();
73
- // True when the row at full size would wrap. The tabs then draw as one
74
- // dropdown, the first control with a smaller self that still does its job.
75
- let compact = $state(false);
81
+ // How far the row has folded to stay on one line. 0 is full size; 1 folds
82
+ // the tabs into a dropdown; 2 also drops the filter and display labels;
83
+ // 3 also turns search into an icon that opens the field across the row.
84
+ type Level = 0 | 1 | 2 | 3;
85
+ const DEEPEST: Level = 3;
86
+ let level = $state<Level>(0);
87
+ let searchOpen = $state(false);
88
+ let searchLayer = $state<HTMLElement>();
76
89
  setToolbarDensity({
77
90
  get compact() {
78
- return compact;
91
+ return level >= 1;
92
+ },
93
+ get tight() {
94
+ return level >= 2;
79
95
  },
80
96
  });
81
97
 
98
+ // =========================
99
+ // HANDLERS
100
+ // =========================
101
+ // Focus lands in the field, since opening it is asking to type.
102
+ async function openSearch() {
103
+ searchOpen = true;
104
+ await tick();
105
+ searchLayer?.querySelector<HTMLElement>("input, [contenteditable]")?.focus();
106
+ }
107
+
82
108
  // =========================
83
109
  // DERIVED
84
110
  // =========================
@@ -101,22 +127,39 @@
101
127
  if (!hasToolbar) return;
102
128
  return section?.claim();
103
129
  });
130
+ // Widened past the last fold, the field is back in the row, so the open
131
+ // layer has nothing left to cover.
132
+ $effect(() => {
133
+ if (level < DEEPEST) searchOpen = false;
134
+ });
104
135
  // One row, never two (Ramzi, 9 Nov 2026): /tasks at 1500px put its status
105
136
  // tabs and actions on a second line under search and filters.
106
- // WHY the tabs' full width is remembered: once folded, the row only holds the
107
- // dropdown, and a probe of it would say the full strip fits when it doesn't.
137
+ // Then 620px and 420px still wrapped, the filters on a line of their own, so
138
+ // a second fold drops their labels (10 Nov 2026).
139
+ // WHY each level's width is remembered: a folded row only holds the smaller
140
+ // controls, and a probe of it can't say whether the larger ones would fit.
141
+ // The probe refreshes the current level's entry, so new counts or chips
142
+ // cost at most one extra step before the row settles.
108
143
  $effect(() => {
109
144
  if (!row) return;
110
145
  const el = row;
111
- let fullTabs = 0;
146
+ const needs = [Infinity, Infinity, Infinity, Infinity];
112
147
  let frame = 0;
148
+ // WHY settled in one pass with flushSync, not a frame per level: a phone
149
+ // load walked 0 to 3 over four frames, and axe read the squeezed search
150
+ // field in between (gallery, 390px).
113
151
  const measure = () => {
114
152
  frame = 0;
115
- const needed = naturalRowWidth(el);
116
- const tabs = tabsBox?.getBoundingClientRect().width ?? 0;
117
- if (!compact) fullTabs = tabs;
118
- const full = compact ? needed - tabs + fullTabs : needed;
119
- compact = full > el.clientWidth + 0.5;
153
+ for (let step = 0; step <= DEEPEST * 2; step++) {
154
+ const room = el.clientWidth + 0.5;
155
+ const at = untrack(() => level);
156
+ needs[at] = naturalRowWidth(el);
157
+ let next = at;
158
+ if (needs[at] > room && at < DEEPEST) next = (at + 1) as Level;
159
+ else if (at > 0 && needs[at - 1] <= room) next = (at - 1) as Level;
160
+ if (next === at) return;
161
+ flushSync(() => (level = next));
162
+ }
120
163
  };
121
164
  // A frame's delay folds a burst of resizes into one probe and keeps the
122
165
  // observer from looping on its own state change.
@@ -160,68 +203,111 @@
160
203
  {#if hasToolbar}
161
204
  <!-- One row on every list page: search and filters left, then tabs,
162
205
  view toggle and primary action right. -->
163
- <!-- Without room, `order` keeps the actions beside search and drops
164
- filters to a scrolling line below. flex-wrap stays on at every width. -->
165
- <!-- A container query, not a breakpoint: an 834px iPad read as desktop
166
- and clipped a 733px toolbar into a 538px column. -->
206
+ <!-- The row never wraps. Short of room it folds the tabs, then drops the
207
+ filter and display labels; past that, search and the filters strip
208
+ shrink and the strip scrolls. The actions never shrink. -->
167
209
  <!-- Sticky, because the section tabs live here. `-mx-4 px-4` covers the
168
210
  gutter, `-mt-4 py-4` pulls the top padding inside the sticky box. -->
211
+ <!-- `data-fold` lets a caller's own control fold too: a primary button
212
+ writes `group-data-[fold=3]/toolbar:sr-only` on its label. -->
169
213
  <div
170
- class="@container/toolbar sticky top-0 z-20 -mx-4 -mt-4 bg-card/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-card/70"
214
+ data-fold={level}
215
+ class="group/toolbar @container/toolbar sticky top-0 z-20 -mx-4 -mt-4 bg-card/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-card/70"
171
216
  >
172
- <!-- Folded and still short of room, the row stops wrapping and the
173
- filters strip scrolls, since it's the one part that already can. -->
174
- <div
175
- bind:this={row}
176
- class={cn(
177
- "flex flex-wrap items-center gap-2",
178
- compact && "@3xl/toolbar:flex-nowrap",
179
- toolbarClass,
180
- )}
181
- >
182
- <!-- The section tabs lead: which page comes before which slice of it.
183
- One row replaces two rows that held four controls. -->
217
+ <!-- WHY the section strip sits outside the measured row: it's the nav
218
+ between pages, and under 768px it takes a line of its own above the
219
+ toolbar. Counted in the probe, it folded controls that had room. -->
220
+ <!-- A container query, not a breakpoint: an 834px iPad read as desktop
221
+ and clipped a 733px toolbar into a 538px column. -->
222
+ <div class="flex flex-wrap items-center gap-2">
184
223
  {#if section?.strip}
185
- <SectionRuns
186
- strip={section.strip}
187
- class="order-first w-full max-w-full @3xl/toolbar:w-auto"
188
- />
189
- {/if}
190
- {#if search}
191
- <div class="order-1 flex min-w-0 flex-1 items-center @3xl/toolbar:flex-none">
192
- {@render search()}
193
- </div>
224
+ <SectionRuns strip={section.strip} class="w-full max-w-full @3xl/toolbar:w-auto" />
194
225
  {/if}
195
- {#if tabs || rightActions}
196
- <!-- Shrinks and wraps, not shrink-0: /tasks needs 610px here, and
197
- shrink-0 clipped its last control off an iPad screen. -->
198
- <div
199
- class={cn(
200
- "order-2 flex min-w-0 flex-wrap items-center gap-2 @3xl/toolbar:order-3",
201
- hasLeading ? "@3xl/toolbar:ms-auto" : "flex-1",
202
- compact && "@3xl/toolbar:shrink-0",
203
- )}
204
- >
205
- {#if tabs}
206
- <div
207
- bind:this={tabsBox}
208
- class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}
209
- >
210
- {@render tabs()}
226
+ <div
227
+ bind:this={row}
228
+ class={cn(
229
+ "relative flex min-w-0 flex-1 flex-nowrap items-center",
230
+ // Icon triggers sit closer: at 390px the last fold was 4px over.
231
+ level >= 2 ? "gap-1.5" : "gap-2",
232
+ toolbarClass,
233
+ )}
234
+ >
235
+ {#if search && level < DEEPEST}
236
+ <div class="flex min-w-0 shrink items-center">
237
+ {@render search()}
238
+ </div>
239
+ {:else if search && !searchOpen}
240
+ <!-- WHY a fold and not a squeeze: at 390px the field shrank under
241
+ axe's 24px target size and showed two letters of its placeholder. -->
242
+ <button
243
+ type="button"
244
+ class={toolbarTriggerClass({
245
+ className: "aspect-square shrink-0 justify-center px-0",
246
+ })}
247
+ aria-label={searchLabel}
248
+ title={searchLabel}
249
+ onclick={openSearch}
250
+ >
251
+ <MagnifyingGlass aria-hidden="true" class="size-3.5" />
252
+ </button>
253
+ {:else if search}
254
+ <!-- Open, the field covers the whole row; Escape or the close
255
+ button hands the row back, with the query kept. -->
256
+ <div
257
+ bind:this={searchLayer}
258
+ role="search"
259
+ class="absolute inset-0 z-10 flex items-center gap-2 bg-card"
260
+ onkeydown={(event) => {
261
+ if (event.key === "Escape") searchOpen = false;
262
+ }}
263
+ >
264
+ <div class="flex min-w-0 flex-1 items-center [&>*]:w-full [&>*]:max-w-none">
265
+ {@render search()}
211
266
  </div>
212
- {/if}
213
- {#if rightActions}
214
- {@render rightActions()}
215
- {/if}
216
- </div>
217
- {/if}
218
- {#if filters}
219
- <div
220
- class="no-scrollbar order-3 flex w-full max-w-full min-w-0 items-center gap-2 overflow-x-auto @3xl/toolbar:order-2 @3xl/toolbar:w-auto"
221
- >
222
- {@render filters()}
223
- </div>
224
- {/if}
267
+ <button
268
+ type="button"
269
+ class={toolbarTriggerClass({
270
+ className: "aspect-square shrink-0 justify-center px-0",
271
+ })}
272
+ aria-label={closeSearchLabel}
273
+ onclick={() => (searchOpen = false)}
274
+ >
275
+ <X aria-hidden="true" class="size-3.5" />
276
+ </button>
277
+ </div>
278
+ {/if}
279
+ {#if filters}
280
+ <!-- Tight, the strip stops shrinking: squeezed under its icon
281
+ trigger, the trigger slid beneath the tabs (420px). Its
282
+ chips scroll inside a capped width instead. -->
283
+ <div
284
+ class={cn(
285
+ "no-scrollbar flex min-w-0 items-center gap-2 overflow-x-auto",
286
+ level >= 2 ? "max-w-40 shrink-0" : "shrink",
287
+ )}
288
+ >
289
+ {@render filters()}
290
+ </div>
291
+ {/if}
292
+ {#if tabs || rightActions}
293
+ <div
294
+ class={cn(
295
+ "flex shrink-0 items-center",
296
+ level >= 2 ? "gap-1.5" : "gap-2",
297
+ hasLeading ? "ms-auto" : "flex-1",
298
+ )}
299
+ >
300
+ {#if tabs}
301
+ <div class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}>
302
+ {@render tabs()}
303
+ </div>
304
+ {/if}
305
+ {#if rightActions}
306
+ {@render rightActions()}
307
+ {/if}
308
+ </div>
309
+ {/if}
310
+ </div>
225
311
  </div>
226
312
  </div>
227
313
  {/if}
@@ -1,4 +1,4 @@
1
- import type { Snippet } from "svelte";
1
+ import { type Snippet } from "svelte";
2
2
  import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
3
3
  type EmptyConfig = {
4
4
  icon?: PhosphorIcon;
@@ -25,6 +25,11 @@ interface Props {
25
25
  content?: Snippet;
26
26
  empty?: Snippet;
27
27
  extra?: Snippet;
28
+ /** The folded search icon's name, on a toolbar too narrow for the field.
29
+ * Translate at the call site. */
30
+ searchLabel?: string;
31
+ /** The close button's name once the folded search is open. */
32
+ closeSearchLabel?: string;
28
33
  }
29
34
  declare const $$Component: import("svelte").Component<Props, {}, "">;
30
35
  type $$Component = ReturnType<typeof $$Component>;
@@ -5,7 +5,11 @@ import { getContext, setContext } from "svelte";
5
5
  * its row and sets this; a control inside it reads it and draws a smaller self.
6
6
  */
7
7
  export interface ToolbarDensity {
8
+ /** The tabs fold into one dropdown. */
8
9
  readonly compact: boolean;
10
+ /** Folded and still short: a filter or display trigger drops its label and
11
+ * keeps the icon, so the row never wraps (TSK-2121, 420px). Implies compact. */
12
+ readonly tight: boolean;
9
13
  }
10
14
 
11
15
  // WHY context: the shell owns the measurement and 26 pages render their tabs
@@ -27,12 +31,31 @@ export function getToolbarDensity(): ToolbarDensity | undefined {
27
31
  */
28
32
  // WHY `max-content` on a probe, not the row's own width: the row is sized by
29
33
  // its container, so reading it back always says "it fits" (see collapse.ts).
34
+ // WHY `flex: none` too: a row that is itself a flex item takes its width from
35
+ // flex-basis, so `max-content` alone read back the container (10 Nov 2026).
36
+ // WHY transitions are stopped: under reduced motion `tokens/base.css` gives every
37
+ // element an 80ms transition on `all`, so the probe's own width change started a
38
+ // transition and the read got the old width. The row never folded at 390px and
39
+ // axe flagged the squeezed search field (gallery, 10 Nov 2026). The last fold's
40
+ // gap and shrink changes are still running then too, so they're finished first.
30
41
  export function naturalRowWidth(row: HTMLElement): number {
31
- const { width, flexWrap } = row.style;
42
+ for (const animation of row.getAnimations({ subtree: true })) {
43
+ if (animation instanceof CSSTransition) animation.finish();
44
+ }
45
+ const { width, flexWrap, flex } = row.style;
46
+ const transition = row.style.getPropertyValue("transition");
47
+ const priority = row.style.getPropertyPriority("transition");
48
+ row.style.setProperty("transition", "none", "important");
32
49
  row.style.width = "max-content";
33
50
  row.style.flexWrap = "nowrap";
51
+ row.style.flex = "none";
34
52
  const needed = row.getBoundingClientRect().width;
35
53
  row.style.width = width;
36
54
  row.style.flexWrap = flexWrap;
55
+ row.style.flex = flex;
56
+ // The restored size has to settle before transitions come back, or the
57
+ // row would animate back from its probe width.
58
+ void row.offsetWidth;
59
+ row.style.setProperty("transition", transition, priority);
37
60
  return needed;
38
61
  }
@@ -18,7 +18,8 @@
18
18
  options?: PickerOption<V>[];
19
19
  /** Titled runs, when the menu has a shape worth naming. */
20
20
  groups?: PickerGroup<V>[];
21
- value: V | null;
21
+ /** An array checks each row in it, and a pick leaves the menu open for the next one. */
22
+ value: V | V[] | null;
22
23
  onSelect: (value: V) => void;
23
24
  /** The control that opens the menu. Rendered inside `Popover.Trigger`. */
24
25
  trigger: Snippet;
@@ -74,10 +75,14 @@
74
75
  const WIDTH = { sm: "w-44", default: "w-52", lg: "w-60" } as const;
75
76
 
76
77
  function pick(next: V) {
77
- open = false;
78
+ if (!Array.isArray(value)) open = false;
78
79
  onSelect(next);
79
80
  }
80
81
 
82
+ function isChecked(option: PickerOption<V>): boolean {
83
+ return Array.isArray(value) ? value.includes(option.value) : option.value === value;
84
+ }
85
+
81
86
  /**
82
87
  * WHY a custom filter: the default one scores `value`, which is often an id.
83
88
  * Typing "an" fuzzy-matched every uuid, so only the visible words are searched.
@@ -152,7 +157,7 @@
152
157
  {#each resolved as group, i (group.heading ?? i)}
153
158
  <Command.Group heading={group.heading}>
154
159
  {#each group.options as option (option.value)}
155
- {@const active = option.value === value}
160
+ {@const active = isChecked(option)}
156
161
  <Command.Item
157
162
  value={option.value}
158
163
  keywords={keywordsFor(option)}
@@ -6,7 +6,8 @@ declare function $$render<V extends string>(): {
6
6
  options?: PickerOption<V>[];
7
7
  /** Titled runs, when the menu has a shape worth naming. */
8
8
  groups?: PickerGroup<V>[];
9
- value: V | null;
9
+ /** An array checks each row in it, and a pick leaves the menu open for the next one. */
10
+ value: V | V[] | null;
10
11
  onSelect: (value: V) => void;
11
12
  /** The control that opens the menu. Rendered inside `Popover.Trigger`. */
12
13
  trigger: Snippet;
@@ -2,6 +2,7 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import { ToggleGroup } from "bits-ui";
5
6
  import { cn } from "../../lib/utils.js";
6
7
  import Segment from "./segment.svelte";
7
8
  import type { TabDef } from "./animated-tabs.svelte";
@@ -26,8 +27,9 @@
26
27
  // WHY: picks which rows are listed, with no panel of its own. That's a
27
28
  // segmented control, not a tab set.
28
29
 
29
- // WHY not `AnimatedTabs`: a strip with no tabpanel is a `group` of `aria-pressed`
30
- // toggles. "Tab 1 of 4" sends a screen reader looking for a panel.
30
+ // WHY not `AnimatedTabs`: a strip with no tabpanel is a bits-ui single toggle group,
31
+ // one Tab stop whose segments report checked. "Tab 1 of 4" sends a screen reader
32
+ // looking for a panel.
31
33
 
32
34
  // WHY `TabDef`: an option and a tab were the same five fields written twice.
33
35
  options: readonly TabDef<T>[];
@@ -107,6 +109,12 @@
107
109
  value = next;
108
110
  onValueChange?.(next);
109
111
  }
112
+
113
+ // bits-ui hands back a plain string, and "" when the picked segment is pressed again.
114
+ function pickByValue(next: string) {
115
+ const option = merged.find((o) => o.value === next);
116
+ if (option) pick(option.value);
117
+ }
110
118
  </script>
111
119
 
112
120
  <!-- =========================== -->
@@ -132,20 +140,21 @@
132
140
  {/snippet}
133
141
  </Picker>
134
142
  {:else}
135
- <div
143
+ <!-- WHY a function binding: a filter always has one option on, so the setter drops the ""
144
+ and the getter keeps showing `value`. -->
145
+ <ToggleGroup.Root
146
+ type="single"
147
+ bind:value={() => value, pickByValue}
136
148
  class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
137
- role="group"
138
149
  aria-label={ariaLabel}
139
150
  >
140
151
  <SegmentedTrack fill={block}>
141
152
  {#each split.visible as option (option.value)}
142
153
  {@const active = value === option.value}
143
154
  {@const markOnly = iconOnly && Boolean(option.image || option.icon)}
144
- <button
145
- type="button"
155
+ <ToggleGroup.Item
156
+ value={option.value}
146
157
  class={segmentedTriggerClass({ fill: block, active })}
147
- onclick={() => pick(option.value)}
148
- aria-pressed={active}
149
158
  aria-label={markOnly ? option.label : undefined}
150
159
  title={markOnly ? option.label : undefined}
151
160
  >
@@ -173,7 +182,7 @@
173
182
  {active}
174
183
  />
175
184
  {/if}
176
- </button>
185
+ </ToggleGroup.Item>
177
186
  {/each}
178
187
  {#snippet after()}
179
188
  {#if split.hidden.length > 0}
@@ -181,5 +190,5 @@
181
190
  {/if}
182
191
  {/snippet}
183
192
  </SegmentedTrack>
184
- </div>
193
+ </ToggleGroup.Root>
185
194
  {/if}
@@ -83,6 +83,7 @@
83
83
  [data-state="active"],
84
84
  [aria-current="page"],
85
85
  [aria-pressed="true"],
86
+ [data-state="on"],
86
87
  [data-state="open"]
87
88
  )
88
89
  ) {
@@ -116,7 +117,9 @@
116
117
  /* Before the attachment measures, the active segment draws the pill itself,
117
118
  so a server-rendered strip is right before any JS arrives. */
118
119
  .track:not([data-pill="on"])
119
- :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
120
+ :global(
121
+ :is([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="on"])
122
+ ) {
120
123
  background-color: var(--color-card);
121
124
  box-shadow: var(--elev-btn), var(--elev-hairline);
122
125
  }
@@ -125,7 +128,9 @@
125
128
  :global(.dark) .pill,
126
129
  :global(.dark)
127
130
  .track:not([data-pill="on"])
128
- :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
131
+ :global(
132
+ :is([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="on"])
133
+ ) {
129
134
  background-color: color-mix(in oklch, var(--color-foreground) 14%, var(--color-card));
130
135
  }
131
136
 
@@ -138,6 +143,7 @@
138
143
  [data-state="active"],
139
144
  [aria-current="page"],
140
145
  [aria-pressed="true"],
146
+ [data-state="on"],
141
147
  [data-state="open"]
142
148
  )
143
149
  ) {
@@ -155,7 +161,9 @@
155
161
  }
156
162
 
157
163
  .track[data-variant="underline"]:not([data-pill="on"])
158
- :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
164
+ :global(
165
+ :is([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="on"])
166
+ ) {
159
167
  background-color: transparent;
160
168
  box-shadow: inset 0 -2px 0 var(--segmented-rule, var(--color-foreground));
161
169
  }
@@ -17,6 +17,7 @@
17
17
  import * as ContextMenu from "../../shadcn/context-menu/index.js";
18
18
  import { Checkbox } from "../../shadcn/checkbox/index.js";
19
19
  import { SortableHead, TableSkeleton } from "../data-table/index.js";
20
+ import GroupHead from "../work-parts/group-head.svelte";
20
21
  import { toggleSort, type SortState } from "../data-table/data-table.js";
21
22
  import { MENU_KEYS, openMenuFromKey } from "../data-table/menu-key.js";
22
23
 
@@ -119,6 +120,9 @@
119
120
  skeletonRows = 5,
120
121
  }: Props = $props();
121
122
 
123
+ // Names each group's body, so a fold button can say which rows it folds.
124
+ const uid = $props.id();
125
+
122
126
  // =========================
123
127
  // DERIVED
124
128
  // =========================
@@ -229,6 +233,15 @@
229
233
  <!-- =========================== -->
230
234
  <!-- MARKUP -->
231
235
  <!-- =========================== -->
236
+ {#snippet groupTitle(tableGroup: TableGroup<Row>, className?: string)}
237
+ <!-- The th sets the type: GroupHead takes the name as markup, so no size of its own lands on it. -->
238
+ <GroupHead class={className}>
239
+ {#snippet title()}
240
+ {#if groupHeader}{@render groupHeader(tableGroup)}{:else}{tableGroup.key}{/if}
241
+ {/snippet}
242
+ </GroupHead>
243
+ {/snippet}
244
+
232
245
  {#snippet body(row: Row)}
233
246
  {#each columns as column (column.key)}
234
247
  <Table.Cell
@@ -403,10 +416,11 @@
403
416
  <TableSkeleton rows={skeletonRows} columns={skeletonColumns} />
404
417
  {:else}
405
418
  {#if group}
406
- {#each entries as entry (entry.id)}
419
+ {#each entries as entry, index (entry.id)}
407
420
  {@const folded = entry.key !== null && collapsible && collapsed.has(entry.key)}
408
421
  {@const tableGroup = { key: entry.key ?? "", rows: entry.rows }}
409
- <Table.Body>
422
+ {@const bodyId = `${uid}-group-${index}`}
423
+ <Table.Body id={bodyId}>
410
424
  {#if entry.key !== null}
411
425
  <Table.Row
412
426
  class="bg-foreground/4 hover:translate-y-0 hover:bg-foreground/4 hover:shadow-none"
@@ -416,6 +430,7 @@
416
430
  <button
417
431
  type="button"
418
432
  aria-expanded={!folded}
433
+ aria-controls={bodyId}
419
434
  class="t-press flex h-8 w-full items-center gap-2 px-2 text-start outline-none focus-visible:shadow-(--ring-focus-inset)"
420
435
  onclick={() => toggleGroup(tableGroup.key)}
421
436
  >
@@ -426,12 +441,13 @@
426
441
  )}
427
442
  aria-hidden="true"
428
443
  />
429
- {#if groupHeader}{@render groupHeader(tableGroup)}{:else}{tableGroup.key}{/if}
444
+ {@render groupTitle(tableGroup)}
430
445
  </button>
431
446
  {:else}
432
- <span class="flex h-8 items-center px-2">
433
- {#if groupHeader}{@render groupHeader(tableGroup)}{:else}{tableGroup.key}{/if}
434
- </span>
447
+ <!-- No gap: unlike the toggle, a fixed head never spaced its parts. -->
448
+ <div class="flex h-8 items-center px-2">
449
+ {@render groupTitle(tableGroup, "gap-0")}
450
+ </div>
435
451
  {/if}
436
452
  </Table.Head>
437
453
  </Table.Row>