@heyramzi/ui 3.0.0 → 3.2.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 (160) hide show
  1. package/dist/charts/dual-axis.js +38 -0
  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/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/dist/slides/deck-controller.svelte.js +62 -15
  11. package/dist/slides/layout.js +3 -2
  12. package/package.json +9 -2
  13. package/scripts/svelte-dts.mjs +3 -0
  14. package/scripts/sync-shadcn.mjs +0 -5
  15. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  16. package/src/canvas/pen/PenOverlay.svelte +1 -30
  17. package/src/canvas/screen/PartView.svelte +13 -8
  18. package/src/canvas/screen/ScreenBody.svelte +18 -11
  19. package/src/canvas/ui/BarTooltip.svelte +46 -0
  20. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  21. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  22. package/src/canvas/ui/StylePanel.svelte +179 -102
  23. package/src/canvas/ui/canvas-ui.css +12 -76
  24. package/src/charts/area-chart.css +6 -0
  25. package/src/charts/area-chart.svelte +115 -17
  26. package/src/charts/area-chart.svelte.d.ts +11 -2
  27. package/src/charts/chart-tokens.css +4 -3
  28. package/src/charts/dashboard/data-table.svelte +80 -80
  29. package/src/charts/dashboard/hint.svelte +34 -0
  30. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  31. package/src/charts/dashboard/rating-chip.svelte +4 -3
  32. package/src/charts/dual-axis.test.ts +40 -0
  33. package/src/charts/dual-axis.ts +43 -0
  34. package/src/charts/legend.css +0 -8
  35. package/src/charts/legend.svelte +26 -30
  36. package/src/components/copy/copy-button.svelte +10 -2
  37. package/src/components/copy/copy-button.test.ts +7 -0
  38. package/src/components/data-table/table-skeleton.svelte +15 -7
  39. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  40. package/src/components/docs/on-this-page.svelte +2 -2
  41. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  42. package/src/components/list-page/filter-item.svelte +20 -7
  43. package/src/components/list-page/filter-popover.svelte +41 -16
  44. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  45. package/src/components/list-page/grouped-list.svelte +41 -23
  46. package/src/components/picker/picker.svelte +8 -3
  47. package/src/components/picker/picker.svelte.d.ts +2 -1
  48. package/src/components/segmented/segmented-filter.svelte +19 -10
  49. package/src/components/segmented/segmented-track.svelte +11 -3
  50. package/src/components/table-view/table-view.svelte +22 -6
  51. package/src/components/tags-input/tags-input.svelte +94 -77
  52. package/src/components/toolbar/toolbar.svelte +2 -1
  53. package/src/components/toolbar/toolbar.test.ts +5 -4
  54. package/src/components/work-parts/group-head.svelte +28 -9
  55. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  56. package/src/editor/editor-toolbar.svelte +231 -193
  57. package/src/editor/editor.svelte +57 -39
  58. package/src/editor/image-toolbar.svelte +99 -37
  59. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  60. package/src/editor/url-dialog.svelte +96 -0
  61. package/src/editor/url-dialog.svelte.d.ts +15 -0
  62. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  63. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  64. package/src/inbox/thread-inbox.svelte +9 -1
  65. package/src/lib/live-region.svelte +12 -4
  66. package/src/lib/live-region.svelte.d.ts +5 -1
  67. package/src/lib/sliding-pill.ts +3 -2
  68. package/src/record/info-list.svelte +33 -59
  69. package/src/record/measure-clamp.ts +31 -0
  70. package/src/record/record-shell.svelte +38 -39
  71. package/src/record/record-timeline.svelte +10 -5
  72. package/src/record/steps-rail.svelte +50 -36
  73. package/src/record/where-we-are.svelte +5 -19
  74. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  75. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  76. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  77. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  78. package/src/shadcn/button-group/button-group.svelte +46 -0
  79. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  80. package/src/shadcn/button-group/index.ts +15 -0
  81. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  82. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  83. package/src/shadcn/input-group/index.ts +22 -0
  84. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  85. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  86. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  87. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  88. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  89. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  90. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  91. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  92. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  93. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  94. package/src/shadcn/input-group/input-group.svelte +24 -0
  95. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  96. package/src/shadcn/item/index.ts +34 -0
  97. package/src/shadcn/item/item-actions.svelte +20 -0
  98. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  99. package/src/shadcn/item/item-content.svelte +23 -0
  100. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  101. package/src/shadcn/item/item-description.svelte +23 -0
  102. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  103. package/src/shadcn/item/item-footer.svelte +20 -0
  104. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  105. package/src/shadcn/item/item-group.svelte +21 -0
  106. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  107. package/src/shadcn/item/item-header.svelte +20 -0
  108. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  109. package/src/shadcn/item/item-media.svelte +42 -0
  110. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  111. package/src/shadcn/item/item-separator.svelte +19 -0
  112. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  113. package/src/shadcn/item/item-title.svelte +20 -0
  114. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  115. package/src/shadcn/item/item.svelte +61 -0
  116. package/src/shadcn/item/item.svelte.d.ts +28 -0
  117. package/src/shadcn/native-select/index.ts +13 -0
  118. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  119. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  120. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  121. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  122. package/src/shadcn/native-select/native-select.svelte +45 -0
  123. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  124. package/src/shadcn/pagination/index.ts +31 -0
  125. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  126. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  127. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  128. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  129. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  130. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  131. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  132. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  133. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  134. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  135. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  136. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  137. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  138. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  139. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  140. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  141. package/src/shadcn/pagination/pagination.svelte +27 -0
  142. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  143. package/src/slides/Controls.svelte +39 -36
  144. package/src/slides/Deck.svelte +23 -10
  145. package/src/slides/Overview.svelte +10 -4
  146. package/src/slides/README.md +1 -1
  147. package/src/slides/Slide.svelte +17 -11
  148. package/src/slides/deck-controller.svelte.ts +62 -16
  149. package/src/slides/layout.ts +3 -2
  150. package/src/slides/theme.css +34 -5
  151. package/src/thread/thread.svelte +15 -8
  152. package/src/work/detail/task-detail.svelte +1 -1
  153. package/src/work/gantt/gantt-task-row.svelte +3 -6
  154. package/src/work/inbox/inbox-card.svelte +7 -5
  155. package/src/work/inbox/inbox-trace.svelte +14 -10
  156. package/src/work/kanban-board.svelte +40 -25
  157. package/src/work/kanban-hidden-rail.svelte +67 -65
  158. package/src/work/task-list.svelte +145 -124
  159. package/src/work/task-list.svelte.test.ts +5 -2
  160. package/src/work/time/time-tracker.svelte +35 -10
@@ -77,36 +77,32 @@
77
77
  <ul class="hr-legend-items">
78
78
  {#each items as item, i (item.label + i)}
79
79
  {@const dimmed = hoveredIndex !== null && hoveredIndex !== i}
80
- <li>
81
- <button
82
- type="button"
83
- class="hr-legend-item"
84
- class:dimmed
85
- onmouseenter={() => setHover(i)}
86
- onmouseleave={() => setHover(null)}
87
- onfocus={() => setHover(i)}
88
- onblur={() => setHover(null)}
89
- >
90
- <span class="hr-legend-marker" style:background={item.color}></span>
91
- <span class="hr-legend-label">{item.label}</span>
92
- {#if showValue}
93
- <span class="hr-legend-value">
94
- {formatValue(item.value)}{#if showPercentage && item.maxValue}<span
95
- class="hr-legend-pct">{formatPercentage(pct(item))}</span
96
- >{/if}
97
- </span>
98
- {/if}
99
- {#if showProgress && item.maxValue}
100
- <span class="hr-legend-track">
101
- <span
102
- class="hr-legend-bar"
103
- style:--hr-bar-scale={pct(item) / 100}
104
- style:background={item.color}
105
- style:animation-delay="{160 + i * 120}ms"
106
- ></span>
107
- </span>
108
- {/if}
109
- </button>
80
+ <!-- A row, not a button: hover only highlights its series, so a Tab stop here did nothing. -->
81
+ <li
82
+ class="hr-legend-item"
83
+ class:dimmed
84
+ onmouseenter={() => setHover(i)}
85
+ onmouseleave={() => setHover(null)}
86
+ >
87
+ <span class="hr-legend-marker" style:background={item.color}></span>
88
+ <span class="hr-legend-label">{item.label}</span>
89
+ {#if showValue}
90
+ <span class="hr-legend-value">
91
+ {formatValue(item.value)}{#if showPercentage && item.maxValue}<span
92
+ class="hr-legend-pct">{formatPercentage(pct(item))}</span
93
+ >{/if}
94
+ </span>
95
+ {/if}
96
+ {#if showProgress && item.maxValue}
97
+ <span class="hr-legend-track">
98
+ <span
99
+ class="hr-legend-bar"
100
+ style:--hr-bar-scale={pct(item) / 100}
101
+ style:background={item.color}
102
+ style:animation-delay="{160 + i * 120}ms"
103
+ ></span>
104
+ </span>
105
+ {/if}
110
106
  </li>
111
107
  {/each}
112
108
  </ul>
@@ -51,6 +51,7 @@
51
51
  // =========================
52
52
  import { Check, Copy, StarFour } from "phosphor-svelte";
53
53
  import { cn } from "../../lib/utils.js";
54
+ import LiveRegion from "../../lib/live-region.svelte";
54
55
  import { buttonVariants } from "../../shadcn/button/index.js";
55
56
  import { copyText } from "./clipboard.js";
56
57
 
@@ -81,6 +82,13 @@
81
82
  // DERIVED
82
83
  // =========================
83
84
  const inline = $derived(text !== undefined && variant === "inline");
85
+ // A chip's name holds the text it shows (WCAG 2.5.3, label in name), so a
86
+ // speech user who says "click acme-4821" reaches it.
87
+ const restingName = $derived(
88
+ text === undefined || label.toLowerCase().includes(text.toLowerCase())
89
+ ? label
90
+ : `${label} ${text}`,
91
+ );
84
92
 
85
93
  // =========================
86
94
  // FUNCTIONS
@@ -107,7 +115,7 @@
107
115
  type="button"
108
116
  onclick={copy}
109
117
  title={label}
110
- aria-label={announce === "name" && copied ? copiedLabel : label}
118
+ aria-label={announce === "name" && copied ? copiedLabel : restingName}
111
119
  class={cn(
112
120
  "group/copy t-press shrink-0 outline-none focus-visible:shadow-(--ring-focus)",
113
121
  text === undefined
@@ -170,6 +178,6 @@
170
178
 
171
179
  <!-- The swap is invisible to a reader who cannot see it. -->
172
180
  {#if announce === "region"}
173
- <span aria-live="polite" class="sr-only">{copied ? copiedLabel : ""}</span>
181
+ <LiveRegion message={copied ? copiedLabel : ""} />
174
182
  {/if}
175
183
  </button>
@@ -60,6 +60,13 @@ describe("CopyButton", () => {
60
60
  done();
61
61
  });
62
62
 
63
+ it("adds the chip's visible text to a label that leaves it out", () => {
64
+ stubClipboard();
65
+ const { button, done } = render({ value: "acme-4821", text: "acme-4821", label: "Copy id" });
66
+ expect(button.getAttribute("aria-label")).toBe("Copy id acme-4821");
67
+ done();
68
+ });
69
+
63
70
  it("announces in a live region by default and keeps its name", async () => {
64
71
  stubClipboard();
65
72
  const { button, target, done } = render({
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  // ========================= IMPORTS
3
+ import { cn } from "../../lib/utils.js";
3
4
  import { Skeleton } from "../../shadcn/skeleton/index.js";
4
5
 
5
6
  // ========================= TYPES
@@ -8,6 +9,8 @@
8
9
  align?: "left" | "center" | "right";
9
10
  /** Show a small circle before the skeleton bar (e.g. logo/avatar column) */
10
11
  circle?: boolean;
12
+ /** Merged onto the cell, for a table whose own cells pad, hide or rule differently. */
13
+ class?: string;
11
14
  };
12
15
 
13
16
  interface Props {
@@ -17,11 +20,13 @@
17
20
  columns?: ColumnDef[];
18
21
  /** Simple mode: just specify column count */
19
22
  columnCount?: number;
23
+ /** Merged onto each row, for a table whose own rows draw a different line. */
24
+ rowClass?: string;
20
25
  class?: string;
21
26
  }
22
27
 
23
28
  // ========================= PROPS
24
- let { rows = 5, columns, columnCount = 6, class: className }: Props = $props();
29
+ let { rows = 5, columns, columnCount = 6, rowClass, class: className }: Props = $props();
25
30
 
26
31
  // ========================= DERIVED
27
32
  let cols = $derived(
@@ -40,19 +45,22 @@
40
45
  <!-- =========================== -->
41
46
  <tbody class={className}>
42
47
  {#each { length: rows } as _, rowIdx (rowIdx)}
48
+ <!-- Placeholders, so a screen reader skips them rather than reading empty rows. -->
43
49
  <tr
44
50
  data-slot="table-row"
45
- class="border-b border-border/70 transition-opacity"
51
+ class={cn("border-b border-border/70 transition-opacity", rowClass)}
52
+ aria-hidden="true"
46
53
  style="animation: skeleton-fade-in var(--duration-slow) var(--ease-smooth-out) calc(var(--duration-stagger) * {rowIdx}) both"
47
54
  >
48
55
  {#each cols as col, colIdx (colIdx)}
49
56
  <td
50
57
  data-slot="table-cell"
51
- class="h-8 px-2 any-pointer-coarse:h-11 {col.align === 'center'
52
- ? 'text-center'
53
- : col.align === 'right'
54
- ? 'text-right'
55
- : ''}"
58
+ class={cn(
59
+ "h-8 px-2 any-pointer-coarse:h-11",
60
+ col.align === "center" && "text-center",
61
+ col.align === "right" && "text-right",
62
+ col.class,
63
+ )}
56
64
  >
57
65
  <div
58
66
  class="flex items-center gap-2 {col.align === 'center'
@@ -3,6 +3,8 @@ type ColumnDef = {
3
3
  align?: "left" | "center" | "right";
4
4
  /** Show a small circle before the skeleton bar (e.g. logo/avatar column) */
5
5
  circle?: boolean;
6
+ /** Merged onto the cell, for a table whose own cells pad, hide or rule differently. */
7
+ class?: string;
6
8
  };
7
9
  interface Props {
8
10
  /** Number of skeleton rows to render */
@@ -11,6 +13,8 @@ interface Props {
11
13
  columns?: ColumnDef[];
12
14
  /** Simple mode: just specify column count */
13
15
  columnCount?: number;
16
+ /** Merged onto each row, for a table whose own rows draw a different line. */
17
+ rowClass?: string;
14
18
  class?: string;
15
19
  }
16
20
  declare const $$Component: import("svelte").Component<Props, {}, "">;
@@ -123,7 +123,7 @@
123
123
  aria-label={item.label}
124
124
  aria-current={current ? "location" : undefined}
125
125
  onclick={() => pick(item.id)}
126
- class="otp-tick group/tick flex h-3.5 w-7 items-center pl-2 any-pointer-coarse:h-6"
126
+ class="otp-tick group/tick flex h-6 w-7 items-center pl-2"
127
127
  >
128
128
  <span
129
129
  class="block h-0.5 rounded-full transition-[width,background-color] duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] {current
@@ -203,8 +203,8 @@
203
203
  {:else}
204
204
  <Popover.Root bind:open>
205
205
  <div class="relative">
206
+ <!-- Named by its own text, the label then the current heading (WCAG 2.5.3, label in name). -->
206
207
  <Popover.Trigger
207
- aria-label={label}
208
208
  class="flex h-11 w-full items-center gap-2.5 px-4 text-left transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] hover:bg-muted/50"
209
209
  >
210
210
  <List size={16} class="shrink-0 text-muted-foreground" aria-hidden="true" />
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  // ========================= IMPORTS
3
3
  import { cn } from "../../lib/utils.js";
4
+ import LiveRegion from "../../lib/live-region.svelte";
4
5
  import { buttonVariants, type ButtonSize, type ButtonVariant } from "../../shadcn/button/index.js";
5
6
  import type { Snippet } from "svelte";
6
7
  import type { HTMLButtonAttributes } from "svelte/elements";
@@ -191,7 +192,7 @@
191
192
  </span>
192
193
  </button>
193
194
  <!-- Outside the button so it isn't part of its name, and mounted empty so the prompt is read when it lands. -->
194
- <span role="status" class="sr-only">{armed ? confirmPrompt : ""}</span>
195
+ <LiveRegion message={armed ? confirmPrompt : ""} />
195
196
 
196
197
  <!-- ========================= STYLE -->
197
198
  <style>
@@ -3,9 +3,10 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import type { Snippet } from "svelte";
6
- import { Checkbox } from "../../shadcn/checkbox/index.js";
6
+ import { DropdownMenu } from "bits-ui";
7
7
  import { Check } from "phosphor-svelte";
8
8
  import { cn } from "../../lib/utils.js";
9
+ import { checkboxBoxClass } from "../../shadcn/checkbox/checkbox-box.js";
9
10
 
10
11
  // =========================
11
12
  // TYPES
@@ -26,10 +27,14 @@
26
27
  <!-- =========================== -->
27
28
  <!-- MARKUP -->
28
29
  <!-- =========================== -->
29
- <button
30
- type="button"
31
- class="flex min-h-8 items-center gap-2 rounded-lg px-2 text-sm transition-colors duration-quick ease-smooth-out hover:bg-foreground/6 motion-reduce:transition-none any-pointer-coarse:min-h-11"
32
- {onclick}
30
+ <!-- WHY both variants are checkbox items: a radio item needs a group value, and `checked` with
31
+ `onclick` is the whole interface callers have. The setter hands the toggle to `onclick`, so
32
+ the caller's state stays the only truth. -->
33
+ <DropdownMenu.CheckboxItem
34
+ bind:checked={() => checked, () => onclick()}
35
+ closeOnSelect={false}
36
+ data-slot="dropdown-menu-checkbox-item"
37
+ class="flex min-h-8 items-center gap-2 rounded-lg px-2 text-sm outline-hidden select-none transition-colors duration-quick ease-smooth-out hover:bg-foreground/6 data-highlighted:bg-foreground/6 motion-reduce:transition-none any-pointer-coarse:min-h-11"
33
38
  >
34
39
  {#if variant === "radio"}
35
40
  <span
@@ -43,7 +48,15 @@
43
48
  {/if}
44
49
  </span>
45
50
  {:else}
46
- <Checkbox {checked} />
51
+ <span
52
+ aria-hidden="true"
53
+ data-state={checked ? "checked" : "unchecked"}
54
+ class={checkboxBoxClass}
55
+ >
56
+ {#if checked}
57
+ <Check aria-hidden="true" size={14} />
58
+ {/if}
59
+ </span>
47
60
  {/if}
48
61
  {@render children()}
49
- </button>
62
+ </DropdownMenu.CheckboxItem>
@@ -2,8 +2,9 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
- import type { Snippet } from "svelte";
6
- import * as Popover from "../../shadcn/popover/index.js";
5
+ import { tick, type Snippet } from "svelte";
6
+ import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
7
+ import * as DropdownMenu from "../../shadcn/dropdown-menu/index.js";
7
8
  import { Funnel as FunnelIcon } from "phosphor-svelte";
8
9
  import { cn } from "../../lib/utils.js";
9
10
  import { Button } from "../../shadcn/button/index.js";
@@ -58,13 +59,28 @@
58
59
  // =========================
59
60
  let hasCount = $derived(count > 0);
60
61
  let isActive = $derived(active ?? hasCount);
62
+
63
+ // =========================
64
+ // STATE
65
+ // =========================
66
+ let content = $state<HTMLElement | null>(null);
67
+
68
+ // =========================
69
+ // HANDLERS
70
+ // =========================
71
+ // Clear unmounts itself, so focus goes back to the menu and the arrows keep working.
72
+ async function clear() {
73
+ onClear?.();
74
+ await tick();
75
+ content?.focus();
76
+ }
61
77
  </script>
62
78
 
63
79
  <!-- =========================== -->
64
80
  <!-- MARKUP -->
65
81
  <!-- =========================== -->
66
- <Popover.Root bind:open>
67
- <Popover.Trigger class={toolbarTriggerClass({ active: isActive, className: triggerClass })}>
82
+ <DropdownMenu.Root bind:open>
83
+ <DropdownMenu.Trigger class={toolbarTriggerClass({ active: isActive, className: triggerClass })}>
68
84
  <IconComponent aria-hidden="true" class="size-3.5" />
69
85
  <span>{label}</span>
70
86
  {#if trailing}
@@ -73,18 +89,27 @@
73
89
  {#if hasCount}
74
90
  <CountChip {count} size="sm" tone="brand" class="ml-0.5" />
75
91
  {/if}
76
- </Popover.Trigger>
77
- <Popover.Content class={cn("p-2", width, contentClass)} {align}>
92
+ </DropdownMenu.Trigger>
93
+ <!-- The popover's surface: rounder, text-sm, sized by `width` and not by the trigger. -->
94
+ <DropdownMenu.Content
95
+ bind:ref={content}
96
+ class={cn("min-w-0 rounded-2xl p-2 text-sm", width, contentClass)}
97
+ {align}
98
+ >
78
99
  {@render children()}
79
100
  {#if onClear && hasCount}
80
- <Button
81
- variant="ghost"
82
- size="sm"
83
- class="mt-2 w-full text-xs text-muted-foreground"
84
- onclick={onClear}
85
- >
86
- {clearLabel}
87
- </Button>
101
+ <DropdownMenuPrimitive.Item closeOnSelect={false} onSelect={clear}>
102
+ {#snippet child({ props })}
103
+ <Button
104
+ {...props}
105
+ variant="ghost"
106
+ size="sm"
107
+ class="mt-2 w-full text-xs text-muted-foreground"
108
+ >
109
+ {clearLabel}
110
+ </Button>
111
+ {/snippet}
112
+ </DropdownMenuPrimitive.Item>
88
113
  {/if}
89
- </Popover.Content>
90
- </Popover.Root>
114
+ </DropdownMenu.Content>
115
+ </DropdownMenu.Root>
@@ -1,4 +1,4 @@
1
- import type { Snippet } from "svelte";
1
+ import { type Snippet } from "svelte";
2
2
  import type { PhosphorIcon } from "../../lib/icon-types.js";
3
3
  interface Props {
4
4
  label: string;
@@ -4,9 +4,10 @@
4
4
  // =========================
5
5
  import { untrack, type Snippet } from "svelte";
6
6
  import { SvelteSet } from "svelte/reactivity";
7
+ import { Accordion } from "bits-ui";
7
8
  import { CaretDown, Plus } from "phosphor-svelte";
8
9
  import { cn } from "../../lib/utils.js";
9
- import CountChip from "../chip/count-chip.svelte";
10
+ import GroupHead from "../work-parts/group-head.svelte";
10
11
 
11
12
  // =========================
12
13
  // TYPES
@@ -58,16 +59,24 @@
58
59
  // =========================
59
60
  // STATE
60
61
  // =========================
62
+ // WHY the collapsed set stays the truth and not the accordion's open list: a group that
63
+ // leaves `groups` and comes back keeps its fold.
61
64
  const collapsed = new SvelteSet<string>(untrack(() => defaultCollapsed));
65
+ const uid = $props.id();
66
+
67
+ // =========================
68
+ // DERIVED
69
+ // =========================
70
+ let openKeys = $derived(groups.map(getKey).filter((key) => !collapsed.has(key)));
62
71
 
63
72
  // =========================
64
73
  // FUNCTIONS
65
74
  // =========================
66
- function toggle(key: string) {
67
- if (collapsed.has(key)) {
68
- collapsed.delete(key);
69
- } else {
70
- collapsed.add(key);
75
+ function setOpen(next: string[]) {
76
+ for (const group of groups) {
77
+ const key = getKey(group);
78
+ if (next.includes(key)) collapsed.delete(key);
79
+ else collapsed.add(key);
71
80
  }
72
81
  onCollapsedChange?.([...collapsed]);
73
82
  }
@@ -76,13 +85,19 @@
76
85
  <!-- =========================== -->
77
86
  <!-- MARKUP -->
78
87
  <!-- =========================== -->
79
- <div class={cn("flex w-full flex-col", className)}>
88
+ <Accordion.Root
89
+ type="multiple"
90
+ bind:value={() => openKeys, setOpen}
91
+ class={cn("flex w-full flex-col", className)}
92
+ >
80
93
  {#each groups as group, index (getKey(group))}
81
94
  {@const key = getKey(group)}
82
95
  {@const isCollapsed = collapsed.has(key)}
96
+ {@const rowsId = `${uid}-rows-${index}`}
83
97
  <!-- Groups part by space, not a rule: the header band already marks where one starts
84
98
  (DESIGN.md rule 19). -->
85
- <div
99
+ <Accordion.Item
100
+ value={key}
86
101
  class={cn(index > 0 && "mt-4")}
87
102
  role={onGroupDragOver || onGroupDrop ? "group" : undefined}
88
103
  ondragover={onGroupDragOver ? (e) => onGroupDragOver(e, group) : undefined}
@@ -94,11 +109,10 @@
94
109
  <div
95
110
  class="group/header hover-row flex min-h-8 items-center gap-1 rounded-lg bg-foreground/4 transition-colors duration-quick ease-smooth-out hover:bg-foreground/6 motion-reduce:transition-none any-pointer-coarse:min-h-11"
96
111
  >
97
- <button
98
- type="button"
112
+ <!-- No Accordion.Header: it would make every group lid a heading. -->
113
+ <Accordion.Trigger
99
114
  class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 self-stretch rounded-lg px-2.5 text-left outline-none focus-visible:shadow-[var(--ring-focus)]"
100
- onclick={() => toggle(key)}
101
- aria-expanded={!isCollapsed}
115
+ aria-controls={rowsId}
102
116
  >
103
117
  <!-- The accordion chevron: it turns on the fast step and holds still under
104
118
  reduced motion, where the open state is aria-expanded and the rows. -->
@@ -110,11 +124,13 @@
110
124
  )}
111
125
  weight="bold"
112
126
  />
113
- {@render header(group, { collapsed: isCollapsed })}
114
- {#if getCount}
115
- <CountChip count={getCount(group)} />
116
- {/if}
117
- </button>
127
+ <!-- h-auto: the band sets the height, so a tall header still grows it. -->
128
+ <GroupHead count={getCount?.(group)} class="h-auto">
129
+ {#snippet title()}
130
+ {@render header(group, { collapsed: isCollapsed })}
131
+ {/snippet}
132
+ </GroupHead>
133
+ </Accordion.Trigger>
118
134
  {#if actions}
119
135
  <div class="row-action flex shrink-0 items-center gap-1 pe-1">
120
136
  {@render actions(group)}
@@ -132,14 +148,16 @@
132
148
  {/if}
133
149
  </div>
134
150
 
135
- {#if !isCollapsed}
136
- <div>
151
+ <!-- bits-ui keeps a closed panel in the DOM, hidden. The rows stay unmounted, so a
152
+ folded group of 200 rows costs nothing. -->
153
+ <Accordion.Content id={rowsId}>
154
+ {#if !isCollapsed}
137
155
  {@render rows(group)}
138
- </div>
139
- {/if}
140
- </div>
156
+ {/if}
157
+ </Accordion.Content>
158
+ </Accordion.Item>
141
159
  {/each}
142
- </div>
160
+ </Accordion.Root>
143
161
 
144
162
  <!-- No entrance motion. Four stacked animations made the last group of a
145
163
  40-row board arrive two seconds late. Working surfaces don't animate in. -->
@@ -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
  }