@heyramzi/ui 3.1.0 → 3.3.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 (168) hide show
  1. package/dist/lib/sliding-pill.js +2 -2
  2. package/dist/record/measure-clamp.js +23 -0
  3. package/dist/shadcn/button-group/index.js +6 -0
  4. package/dist/shadcn/checkbox/checkbox-box.js +3 -0
  5. package/dist/shadcn/drawer/index.js +14 -0
  6. package/dist/shadcn/input-group/index.js +9 -0
  7. package/dist/shadcn/item/index.js +13 -0
  8. package/dist/shadcn/native-select/index.js +6 -0
  9. package/dist/shadcn/pagination/index.js +12 -0
  10. package/package.json +7 -2
  11. package/scripts/sync-shadcn.mjs +0 -6
  12. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  13. package/src/canvas/pen/PenOverlay.svelte +1 -30
  14. package/src/canvas/screen/PartView.svelte +13 -8
  15. package/src/canvas/screen/ScreenBody.svelte +18 -11
  16. package/src/canvas/ui/BarTooltip.svelte +46 -0
  17. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  18. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  19. package/src/canvas/ui/StylePanel.svelte +179 -102
  20. package/src/canvas/ui/canvas-ui.css +12 -76
  21. package/src/charts/dashboard/data-table.svelte +80 -80
  22. package/src/charts/dashboard/hint.svelte +34 -0
  23. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  24. package/src/charts/dashboard/rating-chip.svelte +4 -3
  25. package/src/charts/legend.css +0 -8
  26. package/src/charts/legend.svelte +26 -30
  27. package/src/components/copy/copy-button.svelte +10 -2
  28. package/src/components/copy/copy-button.test.ts +7 -0
  29. package/src/components/data-table/table-skeleton.svelte +15 -7
  30. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  31. package/src/components/docs/on-this-page.svelte +2 -2
  32. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  33. package/src/components/list-page/filter-item.svelte +20 -7
  34. package/src/components/list-page/filter-popover.svelte +41 -16
  35. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  36. package/src/components/list-page/grouped-list.svelte +41 -23
  37. package/src/components/picker/picker.svelte +8 -3
  38. package/src/components/picker/picker.svelte.d.ts +2 -1
  39. package/src/components/segmented/segmented-filter.svelte +19 -10
  40. package/src/components/segmented/segmented-track.svelte +11 -3
  41. package/src/components/table-view/table-view.svelte +22 -6
  42. package/src/components/tags-input/tags-input.svelte +94 -77
  43. package/src/components/toolbar/toolbar.svelte +2 -1
  44. package/src/components/toolbar/toolbar.test.ts +5 -4
  45. package/src/components/work-parts/group-head.svelte +28 -9
  46. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  47. package/src/editor/editor-toolbar.svelte +223 -193
  48. package/src/editor/editor.svelte +56 -38
  49. package/src/editor/image-toolbar.svelte +99 -37
  50. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  51. package/src/editor/url-dialog.svelte +96 -0
  52. package/src/editor/url-dialog.svelte.d.ts +15 -0
  53. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  54. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  55. package/src/inbox/thread-inbox.svelte +9 -1
  56. package/src/lib/live-region.svelte +12 -4
  57. package/src/lib/live-region.svelte.d.ts +5 -1
  58. package/src/lib/sliding-pill.ts +3 -2
  59. package/src/record/info-list.svelte +33 -59
  60. package/src/record/measure-clamp.ts +31 -0
  61. package/src/record/record-shell.svelte +38 -39
  62. package/src/record/record-timeline.svelte +10 -5
  63. package/src/record/steps-rail.svelte +50 -36
  64. package/src/record/where-we-are.svelte +5 -19
  65. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  66. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  67. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  68. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  69. package/src/shadcn/button-group/button-group.svelte +46 -0
  70. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  71. package/src/shadcn/button-group/index.ts +15 -0
  72. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  73. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  74. package/src/shadcn/drawer/drawer-close.svelte +7 -0
  75. package/src/shadcn/drawer/drawer-close.svelte.d.ts +4 -0
  76. package/src/shadcn/drawer/drawer-content.svelte +36 -0
  77. package/src/shadcn/drawer/drawer-content.svelte.d.ts +10 -0
  78. package/src/shadcn/drawer/drawer-description.svelte +17 -0
  79. package/src/shadcn/drawer/drawer-description.svelte.d.ts +4 -0
  80. package/src/shadcn/drawer/drawer-footer.svelte +20 -0
  81. package/src/shadcn/drawer/drawer-footer.svelte.d.ts +5 -0
  82. package/src/shadcn/drawer/drawer-header.svelte +23 -0
  83. package/src/shadcn/drawer/drawer-header.svelte.d.ts +5 -0
  84. package/src/shadcn/drawer/drawer-nested.svelte +12 -0
  85. package/src/shadcn/drawer/drawer-nested.svelte.d.ts +3 -0
  86. package/src/shadcn/drawer/drawer-overlay.svelte +20 -0
  87. package/src/shadcn/drawer/drawer-overlay.svelte.d.ts +4 -0
  88. package/src/shadcn/drawer/drawer-portal.svelte +7 -0
  89. package/src/shadcn/drawer/drawer-portal.svelte.d.ts +3 -0
  90. package/src/shadcn/drawer/drawer-title.svelte +17 -0
  91. package/src/shadcn/drawer/drawer-title.svelte.d.ts +4 -0
  92. package/src/shadcn/drawer/drawer-trigger.svelte +7 -0
  93. package/src/shadcn/drawer/drawer-trigger.svelte.d.ts +4 -0
  94. package/src/shadcn/drawer/drawer.svelte +12 -0
  95. package/src/shadcn/drawer/drawer.svelte.d.ts +3 -0
  96. package/src/shadcn/drawer/index.ts +38 -0
  97. package/src/shadcn/input-group/index.ts +22 -0
  98. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  99. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  100. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  101. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  102. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  103. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  104. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  105. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  106. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  107. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  108. package/src/shadcn/input-group/input-group.svelte +24 -0
  109. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  110. package/src/shadcn/item/index.ts +34 -0
  111. package/src/shadcn/item/item-actions.svelte +20 -0
  112. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  113. package/src/shadcn/item/item-content.svelte +23 -0
  114. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  115. package/src/shadcn/item/item-description.svelte +23 -0
  116. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  117. package/src/shadcn/item/item-footer.svelte +20 -0
  118. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  119. package/src/shadcn/item/item-group.svelte +21 -0
  120. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  121. package/src/shadcn/item/item-header.svelte +20 -0
  122. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  123. package/src/shadcn/item/item-media.svelte +42 -0
  124. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  125. package/src/shadcn/item/item-separator.svelte +19 -0
  126. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  127. package/src/shadcn/item/item-title.svelte +20 -0
  128. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  129. package/src/shadcn/item/item.svelte +61 -0
  130. package/src/shadcn/item/item.svelte.d.ts +28 -0
  131. package/src/shadcn/native-select/index.ts +13 -0
  132. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  133. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  134. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  135. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  136. package/src/shadcn/native-select/native-select.svelte +45 -0
  137. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  138. package/src/shadcn/pagination/index.ts +31 -0
  139. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  140. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  141. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  142. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  143. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  144. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  145. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  146. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  147. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  148. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  149. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  150. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  151. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  152. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  153. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  154. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  155. package/src/shadcn/pagination/pagination.svelte +27 -0
  156. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  157. package/src/slides/Controls.svelte +39 -36
  158. package/src/slides/Deck.svelte +11 -8
  159. package/src/thread/thread.svelte +15 -8
  160. package/src/work/detail/task-detail.svelte +1 -1
  161. package/src/work/gantt/gantt-task-row.svelte +3 -6
  162. package/src/work/inbox/inbox-card.svelte +7 -5
  163. package/src/work/inbox/inbox-trace.svelte +14 -10
  164. package/src/work/kanban-board.svelte +40 -25
  165. package/src/work/kanban-hidden-rail.svelte +67 -65
  166. package/src/work/task-list.svelte +145 -124
  167. package/src/work/task-list.svelte.test.ts +5 -2
  168. package/src/work/time/time-tracker.svelte +35 -10
@@ -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>
@@ -3,6 +3,7 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { tick } from "svelte";
6
+ import { Combobox } from "bits-ui";
6
7
  import { Plus } from "phosphor-svelte";
7
8
  import { cn } from "../../lib/utils.js";
8
9
  import TagChip from "../chip/tag-chip.svelte";
@@ -79,11 +80,11 @@
79
80
  // =========================
80
81
  let editing = $state(false);
81
82
  let query = $state("");
82
- let active = $state(0);
83
83
  // What the list shows while a save is in flight, so a tag doesn't blink out and back.
84
84
  let pending = $state<string[] | null>(null);
85
85
  let input = $state<HTMLInputElement | null>(null);
86
86
  let root = $state<HTMLElement | null>(null);
87
+ let menu = $state<HTMLElement | null>(null);
87
88
  /** What the live region last said: the tags a save added or removed. */
88
89
  let announcement = $state("");
89
90
  const listId = $props.id();
@@ -177,30 +178,35 @@
177
178
 
178
179
  function add(name: string) {
179
180
  query = "";
180
- active = 0;
181
181
  void commit(addTag(tags, name, options, max));
182
182
  }
183
183
 
184
+ // Bits adds the picked row's value to the list (Enter or a click). The name goes through add for its spelling and the max.
185
+ function pick(next: string[]) {
186
+ const name = next.find((entry) => !tags.includes(entry));
187
+ if (name !== undefined) add(name);
188
+ }
189
+
190
+ // The row Bits highlights: a close option when there is one, else the visible Create row.
191
+ function highlighted() {
192
+ const lit = menu?.querySelector("[data-highlighted]")?.getAttribute("data-value");
193
+ return rows.find((row) => row.name === lit) ?? rows[0];
194
+ }
195
+
196
+ // Bits owns the arrows and Enter on a typed query. These keys sit on top of it, and run first.
184
197
  function onKey(event: KeyboardEvent) {
185
- const last = rows.length - 1;
186
- const separator = event.key === "," || (free && event.key === " ");
187
- if (free && (separator || event.key === "Enter" || (event.key === "Tab" && query.trim()))) {
188
- event.preventDefault();
189
- if (query.trim()) add(query);
190
- else if (event.key === "Enter") finish();
191
- } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
192
- if (rows.length === 0) return;
198
+ const typed = query.trim() !== "";
199
+ const separator = event.key === "," || (event.key === "Tab" && typed);
200
+ if (free && (separator || event.key === " " || (event.key === "Enter" && typed))) {
193
201
  event.preventDefault();
194
- const step = event.key === "ArrowDown" ? 1 : -1;
195
- active = (active + step + rows.length) % rows.length;
196
- } else if (event.key === "Enter") {
202
+ if (typed) add(query);
203
+ } else if (!free && separator) {
197
204
  event.preventDefault();
198
- // The highlighted row: a close option when there is one, else the visible Create row.
199
- if (!query.trim()) finish();
200
- else if (rows.length > 0) add(rows[Math.min(active, last)].name);
201
- } else if (event.key === "," || (event.key === "Tab" && query.trim())) {
205
+ const row = highlighted();
206
+ if (typed && row) add(row.name);
207
+ } else if (event.key === "Enter" && !typed) {
202
208
  event.preventDefault();
203
- if (query.trim() && rows.length > 0) add(rows[Math.min(active, last)].name);
209
+ finish();
204
210
  } else if (
205
211
  event.key === "ArrowLeft" &&
206
212
  tags.length > 0 &&
@@ -257,12 +263,6 @@
257
263
  const next = event.relatedTarget as Node | null;
258
264
  if (editing && !(next && root?.contains(next))) stop();
259
265
  }
260
-
261
- // A new query starts from its best match.
262
- $effect(() => {
263
- void query;
264
- active = 0;
265
- });
266
266
  </script>
267
267
 
268
268
  <!-- =========================== -->
@@ -283,6 +283,23 @@
283
283
  </TagChip>
284
284
  {/snippet}
285
285
 
286
+ <!-- The free box is a plain text field. With suggestions, Bits adds the combobox role, state and keys. -->
287
+ {#snippet box(attrs: Record<string, unknown>)}
288
+ <input
289
+ {...attrs}
290
+ bind:this={input}
291
+ bind:value={query}
292
+ type="text"
293
+ aria-label={label}
294
+ autocomplete="off"
295
+ autocapitalize="off"
296
+ spellcheck="false"
297
+ enterkeyhint="done"
298
+ placeholder={tags.length === 0 ? text.add : ""}
299
+ class="h-5 min-w-16 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-xs"
300
+ />
301
+ {/snippet}
302
+
286
303
  <div
287
304
  bind:this={root}
288
305
  class={cn("relative min-w-0", className)}
@@ -298,62 +315,62 @@
298
315
  {/each}
299
316
  {#if full}
300
317
  <span class="px-1 text-xs text-muted-foreground">{text.max}</span>
318
+ {:else if free}
319
+ {@render box({ onkeydown: onKey, onpaste: onPaste })}
301
320
  {:else}
302
- <input
303
- bind:this={input}
304
- bind:value={query}
305
- type="text"
306
- role="combobox"
307
- aria-label={label}
308
- aria-expanded={open}
309
- aria-controls={listId}
310
- aria-autocomplete="list"
311
- aria-activedescendant={open ? `${listId}-${active}` : undefined}
312
- autocomplete="off"
313
- autocapitalize="off"
314
- spellcheck="false"
315
- enterkeyhint="done"
316
- placeholder={tags.length === 0 ? text.add : ""}
317
- class="h-5 min-w-16 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-xs"
318
- onkeydown={onKey}
319
- onpaste={onPaste}
320
- />
321
+ <!-- The list's open state follows the query, so Bits' own open and close requests are dropped. -->
322
+ <Combobox.Root
323
+ type="multiple"
324
+ loop
325
+ bind:value={() => tags, pick}
326
+ bind:open={() => open, () => {}}
327
+ >
328
+ <Combobox.Input onkeydown={onKey} onpaste={onPaste}>
329
+ {#snippet child({ props })}
330
+ <!-- The box shows the query, not the text Bits writes after a pick. -->
331
+ {@const { value: _shown, ...bits } = props}
332
+ {@render box({ ...bits, "aria-controls": open ? listId : undefined })}
333
+ {/snippet}
334
+ </Combobox.Input>
335
+ <Combobox.Portal>
336
+ <!-- WHY customAnchor: the list sits under the whole field, not under the box at the end of the tags. -->
337
+ <Combobox.Content
338
+ bind:ref={menu}
339
+ id={listId}
340
+ aria-label={label}
341
+ customAnchor={root}
342
+ side="bottom"
343
+ align="start"
344
+ sideOffset={4}
345
+ collisionPadding={8}
346
+ class="z-50 max-h-60 w-(--bits-combobox-anchor-width) min-w-48 rounded-xl bg-popover text-popover-foreground shadow-edge-raised"
347
+ >
348
+ <Combobox.Viewport class="p-1">
349
+ {#each rows as row (row.kind + row.name)}
350
+ <Combobox.Item
351
+ value={row.name}
352
+ label={row.name}
353
+ class="flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 text-sm any-pointer-coarse:min-h-11 data-highlighted:bg-foreground/6 data-highlighted:text-foreground"
354
+ >
355
+ {#if row.kind === "create"}
356
+ <Plus class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
357
+ <span class="truncate">{text.create(row.name)}</span>
358
+ {:else}
359
+ <span class="truncate">{row.name}</span>
360
+ {/if}
361
+ </Combobox.Item>
362
+ {/each}
363
+ {#if nothing}
364
+ <div class="flex min-h-8 items-center px-2 text-sm text-muted-foreground">
365
+ {text.none}
366
+ </div>
367
+ {/if}
368
+ </Combobox.Viewport>
369
+ </Combobox.Content>
370
+ </Combobox.Portal>
371
+ </Combobox.Root>
321
372
  {/if}
322
373
  </div>
323
- {#if open}
324
- <ul
325
- id={listId}
326
- role="listbox"
327
- aria-label={label}
328
- class="absolute top-full left-0 z-50 mt-1 max-h-60 w-full min-w-48 overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-edge-raised"
329
- >
330
- {#each rows as row, index (row.kind + row.name)}
331
- <!-- svelte-ignore a11y_click_events_have_key_events -->
332
- <li
333
- id="{listId}-{index}"
334
- role="option"
335
- aria-selected={index === active}
336
- class={cn(
337
- "flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 text-sm any-pointer-coarse:min-h-11",
338
- index === active && "bg-foreground/6 text-foreground",
339
- )}
340
- onmousedown={(e) => e.preventDefault()}
341
- onmousemove={() => (active = index)}
342
- onclick={() => add(row.name)}
343
- >
344
- {#if row.kind === "create"}
345
- <Plus class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
346
- <span class="truncate">{text.create(row.name)}</span>
347
- {:else}
348
- <span class="truncate">{row.name}</span>
349
- {/if}
350
- </li>
351
- {/each}
352
- {#if nothing}
353
- <li class="flex min-h-8 items-center px-2 text-sm text-muted-foreground">{text.none}</li>
354
- {/if}
355
- </ul>
356
- {/if}
357
374
  {:else}
358
375
  <button
359
376
  type="button"
@@ -101,8 +101,9 @@
101
101
  class="@max-2xl/toolbar:hidden"
102
102
  />
103
103
  <DropdownMenu.Root>
104
+ <!-- The name carries the tab it shows (WCAG 2.5.3), so "click Open" reaches it. -->
104
105
  <DropdownMenu.Trigger
105
- aria-label={tabsLabel}
106
+ aria-label={currentTab ? `${tabsLabel}: ${currentTab.label}` : tabsLabel}
106
107
  class={cn(toolbarTriggerClass(), "min-w-0 @2xl/toolbar:hidden")}
107
108
  >
108
109
  <span class="truncate">{currentTab?.label}</span>
@@ -55,22 +55,23 @@ describe("Toolbar", () => {
55
55
  tabsLabel: "Status",
56
56
  viewLabel: "Layout",
57
57
  });
58
- for (const label of ["Find", "Status", "Layout"]) {
58
+ for (const label of ["Find", "Status: All", "Layout"]) {
59
59
  expect(root.querySelector(`button[aria-label="${label}"]`), label).not.toBeNull();
60
60
  }
61
61
  });
62
62
 
63
- it("shows the current tab on the narrow menu trigger", async () => {
63
+ // The name holds the visible tab too (WCAG 2.5.3), so a speech user can say it.
64
+ it("shows the current tab on the narrow menu trigger and names it", async () => {
64
65
  const root = render({ search: "", tabs, tab: "open", views, view: "list" });
65
66
  await tick();
66
- expect(root.querySelector('button[aria-label="Show"]')?.textContent?.trim()).toBe("Open");
67
+ expect(root.querySelector('button[aria-label="Show: Open"]')?.textContent?.trim()).toBe("Open");
67
68
  });
68
69
 
69
70
  it("leaves out what it isn't given", () => {
70
71
  const root = render({ tabs, tab: "all" });
71
72
  expect(root.querySelector('button[aria-label="Search"]')).toBeNull();
72
73
  expect(root.querySelector('button[aria-label="View"]')).toBeNull();
73
- expect(root.querySelector('button[aria-label="Show"]')).not.toBeNull();
74
+ expect(root.querySelector('button[aria-label="Show: All"]')).not.toBeNull();
74
75
  });
75
76
 
76
77
  // The 10 Nov audit: the wide search box had only its placeholder for a name.
@@ -6,10 +6,13 @@
6
6
  import CountChip from "../chip/count-chip.svelte";
7
7
 
8
8
  interface Props {
9
- label: string;
9
+ label?: string;
10
+ /** Drawn in place of `label`, in the caller's own type, for a head whose name is markup. */
11
+ title?: Snippet;
10
12
  /** The status colour of the dot. Leave out for no dot. */
11
13
  hue?: string;
12
- count: number;
14
+ /** Leave out for no count chip. */
15
+ count?: number;
13
16
  /** Shows a plus at the end that adds an item to this group. */
14
17
  onAdd?: () => void;
15
18
  addLabel?: string;
@@ -18,16 +21,32 @@
18
21
  class?: string;
19
22
  }
20
23
 
21
- let { label, hue, count, onAdd, addLabel = "Add", children, class: className }: Props = $props();
24
+ let {
25
+ label,
26
+ title,
27
+ hue,
28
+ count,
29
+ onAdd,
30
+ addLabel = "Add",
31
+ children,
32
+ class: className,
33
+ }: Props = $props();
22
34
  </script>
23
35
 
24
- <!-- A group's head, the same in a kanban column and a list group: dot, name, count, plus. -->
25
- <div class={cn("flex h-8 min-w-0 flex-1 items-center gap-2", className)}>
36
+ <!-- A group's head, the same in a kanban column, a list group, a grouped list and a table group:
37
+ dot, name, count, plus. A span, since two callers put it inside their toggle button. -->
38
+ <span class={cn("flex h-8 min-w-0 flex-1 items-center gap-2", className)}>
26
39
  {#if hue}
27
40
  <span class="size-2 shrink-0 rounded-full" style:background={hue} aria-hidden="true"></span>
28
41
  {/if}
29
- <span class="truncate text-sm font-medium text-foreground">{label}</span>
30
- <CountChip {count} />
42
+ {#if title}
43
+ {@render title()}
44
+ {:else}
45
+ <span class="truncate text-sm font-medium text-foreground">{label}</span>
46
+ {/if}
47
+ {#if count !== undefined}
48
+ <CountChip {count} />
49
+ {/if}
31
50
  {#if children || onAdd}
32
51
  <span class="ms-auto flex shrink-0 items-center gap-0.5">
33
52
  {@render children?.()}
@@ -36,7 +55,7 @@
36
55
  variant="ghost"
37
56
  size="sm"
38
57
  icon
39
- aria-label="{addLabel}: {label}"
58
+ aria-label={label ? `${addLabel}: ${label}` : addLabel}
40
59
  class="text-muted-foreground"
41
60
  onclick={onAdd}
42
61
  >
@@ -45,4 +64,4 @@
45
64
  {/if}
46
65
  </span>
47
66
  {/if}
48
- </div>
67
+ </span>
@@ -1,9 +1,12 @@
1
1
  import type { Snippet } from "svelte";
2
2
  interface Props {
3
- label: string;
3
+ label?: string;
4
+ /** Drawn in place of `label`, in the caller's own type, for a head whose name is markup. */
5
+ title?: Snippet;
4
6
  /** The status colour of the dot. Leave out for no dot. */
5
7
  hue?: string;
6
- count: number;
8
+ /** Leave out for no count chip. */
9
+ count?: number;
7
10
  /** Shows a plus at the end that adds an item to this group. */
8
11
  onAdd?: () => void;
9
12
  addLabel?: string;