@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
@@ -3,6 +3,7 @@
3
3
  import { CaretDown } from "phosphor-svelte";
4
4
  import { cn } from "../lib/utils.js";
5
5
  import { Button } from "../shadcn/button/index.js";
6
+ import * as Collapsible from "../shadcn/collapsible/index.js";
6
7
  import { workLooks } from "../components/work-parts/looks.js";
7
8
  import type { KanbanRailState } from "./kanban-rail-state.svelte.js";
8
9
  import type { KanbanColumn as KanbanColumnDef } from "./kanban-types.js";
@@ -21,79 +22,80 @@
21
22
  let { columns, rail, countOf, hiddenLabel, showAllLabel, hiddenItem }: Props = $props();
22
23
  </script>
23
24
 
24
- <div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
25
- <Button
26
- variant="ghost"
27
- size="sm"
28
- aria-expanded={rail.railOpen}
29
- onclick={() => (rail.railOpen = !rail.railOpen)}
30
- class="justify-start px-2 text-xs font-normal text-muted-foreground"
31
- >
32
- <span
33
- class={cn(
34
- "size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
35
- !rail.railOpen && "-rotate-90",
36
- )}
37
- aria-hidden="true"><CaretDown aria-hidden="true" weight="bold" /></span
38
- >
39
- {hiddenLabel}
40
- </Button>
41
-
42
- {#if rail.railOpen}
43
- <div class="flex flex-col gap-0.5">
44
- {#each columns as column (column.id)}
45
- {@const count = countOf(column.id)}
46
- {@const over = rail.dragOverHidden === column.id}
47
- {@const hue = column.hue ?? "var(--color-primary)"}
48
- <div
49
- role="region"
50
- aria-label={column.ariaLabel ?? column.label}
25
+ <Collapsible.Root bind:open={rail.railOpen} class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
26
+ <Collapsible.Trigger>
27
+ {#snippet child({ props })}
28
+ <Button
29
+ {...props}
30
+ variant="ghost"
31
+ size="sm"
32
+ class="justify-start px-2 text-xs font-normal text-muted-foreground"
33
+ >
34
+ <span
51
35
  class={cn(
52
- "kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
53
- workLooks.row,
54
- rail.leaving.has(column.id) && "kanban-rail-item-leaving",
36
+ "size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
37
+ !rail.railOpen && "-rotate-90",
55
38
  )}
56
- style:background="color-mix(in oklch, {hue}
57
- {over ? 10 : 0}%, transparent)"
58
- style:outline="1px solid color-mix(in oklch, {hue}
59
- {over ? 28 : 0}%, transparent)"
60
- ondragover={(e) => rail.onHiddenDragOver(e, column.id)}
61
- ondragleave={rail.onHiddenDragLeave}
62
- ondrop={(e) => rail.onHiddenDrop(e, column.id)}
39
+ aria-hidden="true"><CaretDown aria-hidden="true" weight="bold" /></span
63
40
  >
64
- <Button
65
- variant="ghost"
66
- size="lg"
67
- onclick={() => rail.show(column.id)}
68
- class="w-full justify-start gap-2 text-xs text-muted-foreground"
69
- >
70
- {#if hiddenItem}
71
- {@render hiddenItem(column, { count, active: over })}
72
- {:else}
73
- <span class="truncate whitespace-nowrap">{column.label}</span>
74
- {#if count > 0}
75
- <span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
76
- >{count.toLocaleString()}</span
77
- >
78
- {/if}
79
- {/if}
80
- </Button>
81
- </div>
82
- {/each}
41
+ {hiddenLabel}
42
+ </Button>
43
+ {/snippet}
44
+ </Collapsible.Trigger>
83
45
 
84
- {#if columns.length > 1}
46
+ <Collapsible.Content class="flex flex-col gap-0.5">
47
+ {#each columns as column (column.id)}
48
+ {@const count = countOf(column.id)}
49
+ {@const over = rail.dragOverHidden === column.id}
50
+ {@const hue = column.hue ?? "var(--color-primary)"}
51
+ <div
52
+ role="region"
53
+ aria-label={column.ariaLabel ?? column.label}
54
+ class={cn(
55
+ "kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
56
+ workLooks.row,
57
+ rail.leaving.has(column.id) && "kanban-rail-item-leaving",
58
+ )}
59
+ style:background="color-mix(in oklch, {hue}
60
+ {over ? 10 : 0}%, transparent)"
61
+ style:outline="1px solid color-mix(in oklch, {hue}
62
+ {over ? 28 : 0}%, transparent)"
63
+ ondragover={(e) => rail.onHiddenDragOver(e, column.id)}
64
+ ondragleave={rail.onHiddenDragLeave}
65
+ ondrop={(e) => rail.onHiddenDrop(e, column.id)}
66
+ >
85
67
  <Button
86
68
  variant="ghost"
87
- size="sm"
88
- onclick={rail.showAll}
89
- class="mt-1 justify-start text-xs font-normal text-muted-foreground"
69
+ size="lg"
70
+ onclick={() => rail.show(column.id)}
71
+ class="w-full justify-start gap-2 text-xs text-muted-foreground"
90
72
  >
91
- {showAllLabel}
73
+ {#if hiddenItem}
74
+ {@render hiddenItem(column, { count, active: over })}
75
+ {:else}
76
+ <span class="truncate whitespace-nowrap">{column.label}</span>
77
+ {#if count > 0}
78
+ <span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
79
+ >{count.toLocaleString()}</span
80
+ >
81
+ {/if}
82
+ {/if}
92
83
  </Button>
93
- {/if}
94
- </div>
95
- {/if}
96
- </div>
84
+ </div>
85
+ {/each}
86
+
87
+ {#if columns.length > 1}
88
+ <Button
89
+ variant="ghost"
90
+ size="sm"
91
+ onclick={rail.showAll}
92
+ class="mt-1 justify-start text-xs font-normal text-muted-foreground"
93
+ >
94
+ {showAllLabel}
95
+ </Button>
96
+ {/if}
97
+ </Collapsible.Content>
98
+ </Collapsible.Root>
97
99
 
98
100
  <style>
99
101
  /* Unlayered: the row's transition-[background-color,outline-color] utility would otherwise drop the fade out. */
@@ -11,6 +11,7 @@
11
11
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
12
12
  import { CaretRight } from "phosphor-svelte";
13
13
  import { Button } from "../shadcn/button/index.js";
14
+ import * as Collapsible from "../shadcn/collapsible/index.js";
14
15
  import GroupHead from "../components/work-parts/group-head.svelte";
15
16
  import { workLooks } from "../components/work-parts/looks.js";
16
17
  import type {
@@ -182,9 +183,10 @@
182
183
  isDroppable: canDrop,
183
184
  });
184
185
 
185
- function toggle(groupId: string): void {
186
+ function setOpen(groupId: string, open: boolean): void {
187
+ if (open !== folded.has(groupId)) return;
186
188
  const next = new Set(folded);
187
- if (next.has(groupId)) next.delete(groupId);
189
+ if (open) next.delete(groupId);
188
190
  else next.add(groupId);
189
191
  folded = next;
190
192
  onCollapsedChange?.([...next]);
@@ -255,134 +257,153 @@
255
257
  {@const open = !folded.has(group.id)}
256
258
  {@const hue = group.hue ?? "var(--color-primary)"}
257
259
  {@const context = { count, active, open, depth, hasChildren: children.length > 0 }}
258
- <section
259
- aria-label={group.ariaLabel ?? group.label}
260
- class={cn(
261
- "tl-group group/tl",
262
- depth === 0 ? "border-b border-border last:border-b-0" : "border-t border-border/60",
263
- )}
264
- class:tl-group-active={active}
265
- style:--tl-hue={hue}
266
- ondragover={children.length ? undefined : (e) => dnd.onColumnDragOver(e, group.id)}
267
- ondragleave={children.length ? undefined : dnd.onDragLeave}
268
- ondrop={children.length ? undefined : (e) => dnd.onDrop(e, group.id)}
269
- >
270
- <div
271
- class={cn(
272
- "hover-row sticky z-10 flex h-10 items-center gap-2 px-3 backdrop-blur-sm",
273
- flat ? "bg-card/90" : "bg-card/90",
274
- depth === 0 && (head ? "top-9" : "top-0"),
275
- )}
276
- style:top={depth > 0 ? `${(head ? 2.25 : 0) + depth * 2.5}rem` : undefined}
277
- style:padding-inline-start={depth > 0 ? `${0.75 + depth * 1.25}rem` : undefined}
278
- >
279
- {#if collapsible}
280
- <Button
281
- variant="ghost"
282
- size="sm"
283
- icon
284
- aria-expanded={open}
285
- aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
286
- onclick={() => toggle(group.id)}
287
- class="-ml-1.5 text-muted-foreground"
260
+ <Collapsible.Root bind:open={() => open, (next) => setOpen(group.id, next)}>
261
+ {#snippet child({ props })}
262
+ <section
263
+ {...props}
264
+ aria-label={group.ariaLabel ?? group.label}
265
+ class={cn(
266
+ "tl-group group/tl",
267
+ depth === 0 ? "border-b border-border last:border-b-0" : "border-t border-border/60",
268
+ )}
269
+ class:tl-group-active={active}
270
+ style:--tl-hue={hue}
271
+ ondragover={children.length ? undefined : (e) => dnd.onColumnDragOver(e, group.id)}
272
+ ondragleave={children.length ? undefined : dnd.onDragLeave}
273
+ ondrop={children.length ? undefined : (e) => dnd.onDrop(e, group.id)}
274
+ >
275
+ <div
276
+ class={cn(
277
+ "hover-row sticky z-10 flex h-10 items-center gap-2 px-3 backdrop-blur-sm",
278
+ flat ? "bg-card/90" : "bg-card/90",
279
+ depth === 0 && (head ? "top-9" : "top-0"),
280
+ )}
281
+ style:top={depth > 0 ? `${(head ? 2.25 : 0) + depth * 2.5}rem` : undefined}
282
+ style:padding-inline-start={depth > 0 ? `${0.75 + depth * 1.25}rem` : undefined}
288
283
  >
289
- <span
290
- class="tl-chevron size-3.5 [&>svg]:size-full"
291
- class:tl-chevron-open={open}
292
- aria-hidden="true"
293
- >
294
- <CaretRight aria-hidden="true" weight="bold" />
295
- </span>
296
- </Button>
297
- {/if}
298
- {#if groupHeader}
299
- {@render groupHeader(group, context)}
300
- {:else}
301
- <GroupHead
302
- label={group.label}
303
- {hue}
304
- {count}
305
- onAdd={onAdd ? () => onAdd(group.id) : undefined}
306
- {addLabel}
307
- />
308
- {/if}
309
- {#if actions}
310
- <div class="tl-actions row-action ms-auto flex shrink-0 items-center gap-1">
311
- {@render actions(group, context)}
312
- </div>
313
- {/if}
314
- </div>
315
-
316
- <!-- inert: a folded group's rows leave the Tab order and the accessibility tree. -->
317
- <div class="tl-fold" class:tl-fold-open={open} inert={!open}>
318
- <div class="tl-fold-inner">
319
- <div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
320
- {#each rows as item, index (getId(item))}
321
- {@const id = getId(item)}
322
- {@const rowId = `${uid}-row-${encodeURIComponent(id)}`}
323
- {@const dropBefore =
324
- dnd.showIndicatorBefore(group.id, id) &&
325
- !dnd.isDragSource(id) &&
326
- idAt(rows, index - 1) !== dnd.draggedId}
327
- <!-- WHY: a row is a list item that takes focus for the Alt+arrow move; making it a button would hide the list from screen readers. -->
328
- <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
329
- <div
330
- role="listitem"
331
- tabindex={draggable ? 0 : undefined}
332
- id={rowId}
333
- data-task-row={id}
334
- aria-label={getLabel?.(item)}
335
- aria-labelledby={draggable && !getLabel ? rowId : undefined}
336
- aria-describedby={draggable ? hintId : undefined}
337
- aria-keyshortcuts={draggable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
338
- draggable={draggable ? "true" : undefined}
339
- animate:flip={{ duration: () => durationMs("fast"), easing: cubicOut }}
340
- ondragstart={draggable
341
- ? (e) => {
342
- liftDragImage(e);
343
- dnd.onDragStart(e, id);
344
- }
345
- : undefined}
346
- ondragend={draggable ? () => dnd.resetDrag() : undefined}
347
- ondragover={draggable
348
- ? (e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))
349
- : undefined}
350
- onkeydown={draggable ? (e) => void onRowKeyDown(e, id) : undefined}
351
- style:--tl-i={Math.min(index, STAGGER_CAP)}
352
- class={cn(
353
- "tl-row relative mx-1.5 outline-none focus-visible:shadow-(--ring-focus)",
354
- workLooks.row,
355
- workLooks.rowHeight,
356
- draggable && "cursor-grab active:cursor-grabbing",
357
- dropBefore && "tl-drop-before",
358
- dnd.isDragSource(id) && "opacity-40",
359
- )}
360
- >
361
- {@render renderRow(item, { groupId: group.id, index })}
362
- </div>
363
- {/each}
364
-
365
- {#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
366
- <div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
284
+ {#if collapsible}
285
+ <Collapsible.Trigger>
286
+ {#snippet child({ props })}
287
+ <Button
288
+ {...props}
289
+ variant="ghost"
290
+ size="sm"
291
+ icon
292
+ aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
293
+ class="-ml-1.5 text-muted-foreground"
294
+ >
295
+ <span
296
+ class="tl-chevron size-3.5 [&>svg]:size-full"
297
+ class:tl-chevron-open={open}
298
+ aria-hidden="true"
299
+ >
300
+ <CaretRight aria-hidden="true" weight="bold" />
301
+ </span>
302
+ </Button>
303
+ {/snippet}
304
+ </Collapsible.Trigger>
367
305
  {/if}
368
-
369
- {#if groupFooter}
370
- {@render groupFooter(group, context)}
306
+ {#if groupHeader}
307
+ {@render groupHeader(group, context)}
308
+ {:else}
309
+ <GroupHead
310
+ label={group.label}
311
+ {hue}
312
+ {count}
313
+ onAdd={onAdd ? () => onAdd(group.id) : undefined}
314
+ {addLabel}
315
+ />
371
316
  {/if}
372
-
373
- {#if rows.length === 0 && children.length === 0 && !active}
374
- <p class={cn(workLooks.rowHole, "mx-1.5 px-3 py-3 text-xs text-muted-foreground")}>
375
- {emptyLabel}
376
- </p>
317
+ {#if actions}
318
+ <div class="tl-actions row-action ms-auto flex shrink-0 items-center gap-1">
319
+ {@render actions(group, context)}
320
+ </div>
377
321
  {/if}
378
322
  </div>
379
323
 
380
- {#each children as child (child.id)}
381
- {@render section(child, depth + 1)}
382
- {/each}
383
- </div>
384
- </div>
385
- </section>
324
+ <!-- inert: a folded group's rows leave the Tab order and the accessibility tree. -->
325
+ <Collapsible.Content forceMount>
326
+ {#snippet child({ props })}
327
+ <div {...props} class="tl-fold" class:tl-fold-open={open}>
328
+ <!-- On the inner box: beside the spread Svelte writes inert as an attribute, and
329
+ clearing it leaves the rows inert in the unit tests' DOM. -->
330
+ <div class="tl-fold-inner" inert={!open}>
331
+ <div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
332
+ {#each rows as item, index (getId(item))}
333
+ {@const id = getId(item)}
334
+ {@const rowId = `${uid}-row-${encodeURIComponent(id)}`}
335
+ {@const dropBefore =
336
+ dnd.showIndicatorBefore(group.id, id) &&
337
+ !dnd.isDragSource(id) &&
338
+ idAt(rows, index - 1) !== dnd.draggedId}
339
+ <!-- WHY: a row is a list item that takes focus for the Alt+arrow move; making it a button would hide the list from screen readers. -->
340
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
341
+ <div
342
+ role="listitem"
343
+ tabindex={draggable ? 0 : undefined}
344
+ id={rowId}
345
+ data-task-row={id}
346
+ aria-label={getLabel?.(item)}
347
+ aria-labelledby={draggable && !getLabel ? rowId : undefined}
348
+ aria-describedby={draggable ? hintId : undefined}
349
+ aria-keyshortcuts={draggable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
350
+ draggable={draggable ? "true" : undefined}
351
+ animate:flip={{ duration: () => durationMs("fast"), easing: cubicOut }}
352
+ ondragstart={draggable
353
+ ? (e) => {
354
+ liftDragImage(e);
355
+ dnd.onDragStart(e, id);
356
+ }
357
+ : undefined}
358
+ ondragend={draggable ? () => dnd.resetDrag() : undefined}
359
+ ondragover={draggable
360
+ ? (e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))
361
+ : undefined}
362
+ onkeydown={draggable ? (e) => void onRowKeyDown(e, id) : undefined}
363
+ style:--tl-i={Math.min(index, STAGGER_CAP)}
364
+ class={cn(
365
+ "tl-row relative mx-1.5 outline-none focus-visible:shadow-(--ring-focus)",
366
+ workLooks.row,
367
+ workLooks.rowHeight,
368
+ draggable && "cursor-grab active:cursor-grabbing",
369
+ dropBefore && "tl-drop-before",
370
+ dnd.isDragSource(id) && "opacity-40",
371
+ )}
372
+ >
373
+ {@render renderRow(item, { groupId: group.id, index })}
374
+ </div>
375
+ {/each}
376
+
377
+ {#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
378
+ <div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
379
+ {/if}
380
+
381
+ {#if groupFooter}
382
+ {@render groupFooter(group, context)}
383
+ {/if}
384
+
385
+ {#if rows.length === 0 && children.length === 0 && !active}
386
+ <p
387
+ class={cn(
388
+ workLooks.rowHole,
389
+ "mx-1.5 px-3 py-3 text-xs text-muted-foreground",
390
+ )}
391
+ >
392
+ {emptyLabel}
393
+ </p>
394
+ {/if}
395
+ </div>
396
+
397
+ {#each children as sub (sub.id)}
398
+ {@render section(sub, depth + 1)}
399
+ {/each}
400
+ </div>
401
+ </div>
402
+ {/snippet}
403
+ </Collapsible.Content>
404
+ </section>
405
+ {/snippet}
406
+ </Collapsible.Root>
386
407
  {/snippet}
387
408
 
388
409
  <div
@@ -54,9 +54,12 @@ async function settle() {
54
54
  flushSync();
55
55
  }
56
56
 
57
- // Svelte sets inert as a property, and jsdom doesn't reflect it to the attribute.
57
+ // jsdom doesn't reflect inert between property and attribute, and Svelte sets the attribute
58
+ // when the element also takes a spread (the Collapsible's props), so read both.
58
59
  function insideInert(el: HTMLElement | null): boolean {
59
- for (let node = el; node; node = node.parentElement) if (node.inert) return true;
60
+ for (let node = el; node; node = node.parentElement) {
61
+ if (node.inert || node.hasAttribute("inert")) return true;
62
+ }
60
63
  return false;
61
64
  }
62
65
 
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" generics="T">
2
2
  import type { Snippet } from "svelte";
3
+ import { Meter } from "bits-ui";
3
4
  import { cn } from "../../lib/utils.js";
4
5
  import { initial } from "../../lib/initials.js";
5
6
  import { TONE_COLOR, TONE_TEXT } from "../../lib/tone.js";
@@ -84,6 +85,10 @@
84
85
 
85
86
  let labels = $derived({ ...DEFAULT_TIME_LABELS, ...labelOverrides });
86
87
 
88
+ const uid = $props.id();
89
+ const invalidId = `${uid}-invalid`;
90
+ const loggedId = `${uid}-logged`;
91
+
87
92
  // ---- timer
88
93
  let now = $state(Date.now());
89
94
  let open = $state(false);
@@ -143,7 +148,7 @@
143
148
  let note = $state("");
144
149
  let saving = $state(false);
145
150
  let invalid = $state(false);
146
- let durationInput = $state<HTMLInputElement>();
151
+ let durationInput = $state<HTMLInputElement | null>(null);
147
152
  let parsed = $derived(parseMinutes(duration));
148
153
  let preview = $derived(
149
154
  parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "",
@@ -170,6 +175,14 @@
170
175
 
171
176
  // ---- summary
172
177
  let total = $derived(entries.reduce((sum, entry) => sum + getMinutes(entry), 0));
178
+ // What the trigger shows, so its name holds it (WCAG 2.5.3, label in name).
179
+ let shown = $derived(
180
+ running
181
+ ? `${clock.hours}:${clock.minutes}:${clock.seconds}`
182
+ : total > 0
183
+ ? `${formatMinutes(total)}${estimate ? ` / ${formatMinutes(estimate)}` : ""}`
184
+ : labels.timer,
185
+ );
173
186
  let liveTotal = $derived(total + elapsedMinutes);
174
187
  let tone = $derived(budgetTone(liveTotal, estimate));
175
188
  let percent = $derived(estimate ? Math.round((liveTotal / estimate) * 100) : 0);
@@ -245,7 +258,7 @@
245
258
  buttonVariants({ variant: "ghost", size: "sm" }),
246
259
  "time-open gap-1.5 ps-1 pe-2 text-(length:--text-control) tabular-nums data-[state=open]:bg-muted",
247
260
  )}
248
- aria-label={labels.title}
261
+ aria-label="{labels.title}: {shown}"
249
262
  >
250
263
  {#if running}
251
264
  <TimeDot running size={6} />
@@ -278,7 +291,7 @@
278
291
  placeholder={labels.durationPlaceholder}
279
292
  aria-label={labels.duration}
280
293
  aria-invalid={invalid}
281
- aria-describedby={invalid ? "time-invalid" : undefined}
294
+ aria-describedby={invalid ? invalidId : undefined}
282
295
  autocomplete="off"
283
296
  inputmode="text"
284
297
  class={cn(
@@ -313,20 +326,33 @@
313
326
  </Button>
314
327
  </form>
315
328
  {#if invalid}
316
- <p id="time-invalid" class="text-xs text-destructive-ink" role="alert">{labels.invalid}</p>
329
+ <p id={invalidId} class="text-xs text-destructive-ink" role="alert">{labels.invalid}</p>
317
330
  {/if}
318
331
 
319
332
  {#if entries.length > 0 || estimate}
320
333
  <div class="flex flex-col gap-2 border-t border-border/60 pt-3">
321
334
  <div class="flex items-baseline justify-between gap-2">
322
- <span class="text-xs font-medium text-muted-foreground">{labels.logged}</span>
335
+ <span id={loggedId} class="text-xs font-medium text-muted-foreground"
336
+ >{labels.logged}</span
337
+ >
323
338
  {#key total}
324
339
  <span class="time-pop font-medium tabular-nums">{formatMinutes(total)}</span>
325
340
  {/key}
326
341
  </div>
327
342
  {#if estimate}
343
+ {@const budget =
344
+ liveTotal > estimate
345
+ ? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
346
+ : labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
328
347
  <div class="flex flex-col gap-1">
329
- <div class={cn("h-1 overflow-hidden bg-muted", ganttLooks.track)} aria-hidden="true">
348
+ <!-- The meter speaks the budget line, so the line below stays out of the reading order. -->
349
+ <Meter.Root
350
+ value={Math.min(liveTotal, estimate)}
351
+ max={estimate}
352
+ aria-labelledby={loggedId}
353
+ aria-valuetext={budget}
354
+ class={cn("h-1 overflow-hidden bg-muted", ganttLooks.track)}
355
+ >
330
356
  <div
331
357
  class={cn("time-segment h-full", ganttLooks.track)}
332
358
  style:background={tone === "neutral"
@@ -334,16 +360,15 @@
334
360
  : TONE_COLOR[tone]}
335
361
  style:width="{Math.min(100, percent)}%"
336
362
  ></div>
337
- </div>
363
+ </Meter.Root>
338
364
  <p
339
365
  class={cn(
340
366
  "text-xs tabular-nums",
341
367
  tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone],
342
368
  )}
369
+ aria-hidden="true"
343
370
  >
344
- {liveTotal > estimate
345
- ? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
346
- : labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
371
+ {budget}
347
372
  </p>
348
373
  </div>
349
374
  {/if}