@heyramzi/ui 3.1.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 (144) 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/input-group/index.js +9 -0
  6. package/dist/shadcn/item/index.js +13 -0
  7. package/dist/shadcn/native-select/index.js +6 -0
  8. package/dist/shadcn/pagination/index.js +12 -0
  9. package/package.json +1 -1
  10. package/scripts/sync-shadcn.mjs +0 -5
  11. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  12. package/src/canvas/pen/PenOverlay.svelte +1 -30
  13. package/src/canvas/screen/PartView.svelte +13 -8
  14. package/src/canvas/screen/ScreenBody.svelte +18 -11
  15. package/src/canvas/ui/BarTooltip.svelte +46 -0
  16. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  17. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  18. package/src/canvas/ui/StylePanel.svelte +179 -102
  19. package/src/canvas/ui/canvas-ui.css +12 -76
  20. package/src/charts/dashboard/data-table.svelte +80 -80
  21. package/src/charts/dashboard/hint.svelte +34 -0
  22. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  23. package/src/charts/dashboard/rating-chip.svelte +4 -3
  24. package/src/charts/legend.css +0 -8
  25. package/src/charts/legend.svelte +26 -30
  26. package/src/components/copy/copy-button.svelte +10 -2
  27. package/src/components/copy/copy-button.test.ts +7 -0
  28. package/src/components/data-table/table-skeleton.svelte +15 -7
  29. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  30. package/src/components/docs/on-this-page.svelte +2 -2
  31. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  32. package/src/components/list-page/filter-item.svelte +20 -7
  33. package/src/components/list-page/filter-popover.svelte +41 -16
  34. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  35. package/src/components/list-page/grouped-list.svelte +41 -23
  36. package/src/components/picker/picker.svelte +8 -3
  37. package/src/components/picker/picker.svelte.d.ts +2 -1
  38. package/src/components/segmented/segmented-filter.svelte +19 -10
  39. package/src/components/segmented/segmented-track.svelte +11 -3
  40. package/src/components/table-view/table-view.svelte +22 -6
  41. package/src/components/tags-input/tags-input.svelte +94 -77
  42. package/src/components/toolbar/toolbar.svelte +2 -1
  43. package/src/components/toolbar/toolbar.test.ts +5 -4
  44. package/src/components/work-parts/group-head.svelte +28 -9
  45. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  46. package/src/editor/editor-toolbar.svelte +223 -193
  47. package/src/editor/editor.svelte +56 -38
  48. package/src/editor/image-toolbar.svelte +99 -37
  49. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  50. package/src/editor/url-dialog.svelte +96 -0
  51. package/src/editor/url-dialog.svelte.d.ts +15 -0
  52. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  53. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  54. package/src/inbox/thread-inbox.svelte +9 -1
  55. package/src/lib/live-region.svelte +12 -4
  56. package/src/lib/live-region.svelte.d.ts +5 -1
  57. package/src/lib/sliding-pill.ts +3 -2
  58. package/src/record/info-list.svelte +33 -59
  59. package/src/record/measure-clamp.ts +31 -0
  60. package/src/record/record-shell.svelte +38 -39
  61. package/src/record/record-timeline.svelte +10 -5
  62. package/src/record/steps-rail.svelte +50 -36
  63. package/src/record/where-we-are.svelte +5 -19
  64. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  65. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  66. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  67. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  68. package/src/shadcn/button-group/button-group.svelte +46 -0
  69. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  70. package/src/shadcn/button-group/index.ts +15 -0
  71. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  72. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  73. package/src/shadcn/input-group/index.ts +22 -0
  74. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  75. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  76. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  77. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  78. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  79. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  80. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  81. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  82. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  83. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  84. package/src/shadcn/input-group/input-group.svelte +24 -0
  85. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  86. package/src/shadcn/item/index.ts +34 -0
  87. package/src/shadcn/item/item-actions.svelte +20 -0
  88. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  89. package/src/shadcn/item/item-content.svelte +23 -0
  90. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  91. package/src/shadcn/item/item-description.svelte +23 -0
  92. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  93. package/src/shadcn/item/item-footer.svelte +20 -0
  94. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  95. package/src/shadcn/item/item-group.svelte +21 -0
  96. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  97. package/src/shadcn/item/item-header.svelte +20 -0
  98. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  99. package/src/shadcn/item/item-media.svelte +42 -0
  100. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  101. package/src/shadcn/item/item-separator.svelte +19 -0
  102. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  103. package/src/shadcn/item/item-title.svelte +20 -0
  104. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  105. package/src/shadcn/item/item.svelte +61 -0
  106. package/src/shadcn/item/item.svelte.d.ts +28 -0
  107. package/src/shadcn/native-select/index.ts +13 -0
  108. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  109. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  110. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  111. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  112. package/src/shadcn/native-select/native-select.svelte +45 -0
  113. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  114. package/src/shadcn/pagination/index.ts +31 -0
  115. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  116. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  117. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  118. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  119. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  120. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  121. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  122. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  123. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  124. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  125. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  126. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  127. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  128. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  129. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  130. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  131. package/src/shadcn/pagination/pagination.svelte +27 -0
  132. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  133. package/src/slides/Controls.svelte +39 -36
  134. package/src/slides/Deck.svelte +11 -8
  135. package/src/thread/thread.svelte +15 -8
  136. package/src/work/detail/task-detail.svelte +1 -1
  137. package/src/work/gantt/gantt-task-row.svelte +3 -6
  138. package/src/work/inbox/inbox-card.svelte +7 -5
  139. package/src/work/inbox/inbox-trace.svelte +14 -10
  140. package/src/work/kanban-board.svelte +40 -25
  141. package/src/work/kanban-hidden-rail.svelte +67 -65
  142. package/src/work/task-list.svelte +145 -124
  143. package/src/work/task-list.svelte.test.ts +5 -2
  144. package/src/work/time/time-tracker.svelte +35 -10
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ // A span whose `text` shows as a tooltip on hover and on focus. A native title never
3
+ // showed on focus, so the keyboard never reached it.
4
+ import type { Snippet } from "svelte";
5
+ import * as Tooltip from "../../shadcn/tooltip/index.js";
6
+ import { cn } from "../../lib/utils.js";
7
+
8
+ interface Props {
9
+ /** Left out, the span is plain and takes no focus. */
10
+ text?: string;
11
+ class?: string;
12
+ children: Snippet;
13
+ }
14
+
15
+ let { text, class: className, children }: Props = $props();
16
+ </script>
17
+
18
+ {#if text}
19
+ <Tooltip.Root>
20
+ <Tooltip.Trigger>
21
+ {#snippet child({ props })}
22
+ <span
23
+ {...props}
24
+ class={cn("rounded-xs outline-none focus-visible:shadow-(--ring-focus)", className)}
25
+ >
26
+ {@render children()}
27
+ </span>
28
+ {/snippet}
29
+ </Tooltip.Trigger>
30
+ <Tooltip.Content>{text}</Tooltip.Content>
31
+ </Tooltip.Root>
32
+ {:else}
33
+ <span class={className}>{@render children()}</span>
34
+ {/if}
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from "svelte";
2
+ interface Props {
3
+ /** Left out, the span is plain and takes no focus. */
4
+ text?: string;
5
+ class?: string;
6
+ children: Snippet;
7
+ }
8
+ declare const $$Component: import("svelte").Component<Props, {}, "">;
9
+ type $$Component = ReturnType<typeof $$Component>;
10
+ export default $$Component;
@@ -2,6 +2,7 @@
2
2
  // One A to E grade on the shared tone scale, so a C means one thing everywhere.
3
3
  import { TONE_TINT, type Tone } from "../../lib/tone.js";
4
4
  import { cn } from "../../lib/utils.js";
5
+ import Hint from "./hint.svelte";
5
6
 
6
7
  interface Props {
7
8
  grade: string;
@@ -21,13 +22,13 @@
21
22
  };
22
23
  </script>
23
24
 
24
- <span
25
+ <Hint
26
+ text={hint}
25
27
  class={cn(
26
28
  "inline-flex h-6 min-w-6 items-center justify-center rounded-md px-1.5 text-xs font-semibold tabular-nums",
27
29
  TONE_TINT[GRADE_TONE[grade] ?? "neutral"],
28
30
  className,
29
31
  )}
30
- title={hint}
31
32
  >
32
33
  {grade}
33
- </span>
34
+ </Hint>
@@ -39,13 +39,10 @@
39
39
  align-items: center;
40
40
  gap: 0.5rem 0.625rem;
41
41
  padding: 0.375rem 0.5rem;
42
- border: 0;
43
42
  border-radius: 0.5rem;
44
43
  background: transparent;
45
- font: inherit;
46
44
  color: var(--foreground);
47
45
  text-align: left;
48
- cursor: pointer;
49
46
  transition:
50
47
  background 160ms ease,
51
48
  opacity 160ms ease;
@@ -57,11 +54,6 @@
57
54
  padding: 0.25rem 0.375rem;
58
55
  }
59
56
 
60
- .hr-legend-item:focus-visible {
61
- background: var(--muted);
62
- outline: none;
63
- }
64
-
65
57
  @media (hover: hover) and (pointer: fine) {
66
58
  .hr-legend-item:hover {
67
59
  background: var(--muted);
@@ -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;