@heyramzi/ui 3.1.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/components/list-page/toolbar-density.js +20 -1
  2. package/dist/lib/sliding-pill.js +2 -2
  3. package/dist/record/measure-clamp.js +23 -0
  4. package/dist/shadcn/button-group/index.js +6 -0
  5. package/dist/shadcn/checkbox/checkbox-box.js +3 -0
  6. package/dist/shadcn/drawer/index.js +14 -0
  7. package/dist/shadcn/input-group/index.js +9 -0
  8. package/dist/shadcn/item/index.js +13 -0
  9. package/dist/shadcn/native-select/index.js +6 -0
  10. package/dist/shadcn/pagination/index.js +12 -0
  11. package/package.json +7 -2
  12. package/scripts/sync-shadcn.mjs +0 -6
  13. package/src/ai/generation-progress/GenerationProgress.svelte +14 -12
  14. package/src/canvas/pen/PenOverlay.svelte +1 -30
  15. package/src/canvas/screen/PartView.svelte +13 -8
  16. package/src/canvas/screen/ScreenBody.svelte +18 -11
  17. package/src/canvas/ui/BarTooltip.svelte +46 -0
  18. package/src/canvas/ui/BarTooltip.svelte.d.ts +17 -0
  19. package/src/canvas/ui/CanvasToolbar.svelte +170 -156
  20. package/src/canvas/ui/StylePanel.svelte +179 -102
  21. package/src/canvas/ui/canvas-ui.css +12 -76
  22. package/src/charts/dashboard/data-table.svelte +80 -80
  23. package/src/charts/dashboard/hint.svelte +34 -0
  24. package/src/charts/dashboard/hint.svelte.d.ts +10 -0
  25. package/src/charts/dashboard/rating-chip.svelte +4 -3
  26. package/src/charts/legend.css +0 -8
  27. package/src/charts/legend.svelte +26 -30
  28. package/src/components/copy/copy-button.svelte +10 -2
  29. package/src/components/copy/copy-button.test.ts +7 -0
  30. package/src/components/data-table/table-skeleton.svelte +15 -7
  31. package/src/components/data-table/table-skeleton.svelte.d.ts +4 -0
  32. package/src/components/docs/on-this-page.svelte +2 -2
  33. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -1
  34. package/src/components/list-page/filter-item.svelte +20 -7
  35. package/src/components/list-page/filter-popover.svelte +57 -17
  36. package/src/components/list-page/filter-popover.svelte.d.ts +1 -1
  37. package/src/components/list-page/grouped-list.svelte +41 -23
  38. package/src/components/list-page/list-page-shell.svelte +155 -69
  39. package/src/components/list-page/list-page-shell.svelte.d.ts +6 -1
  40. package/src/components/list-page/toolbar-density.ts +24 -1
  41. package/src/components/picker/picker.svelte +8 -3
  42. package/src/components/picker/picker.svelte.d.ts +2 -1
  43. package/src/components/segmented/segmented-filter.svelte +19 -10
  44. package/src/components/segmented/segmented-track.svelte +11 -3
  45. package/src/components/table-view/table-view.svelte +22 -6
  46. package/src/components/tags-input/tags-input.svelte +94 -77
  47. package/src/components/toolbar/toolbar.svelte +2 -1
  48. package/src/components/toolbar/toolbar.test.ts +5 -4
  49. package/src/components/work-parts/group-head.svelte +28 -9
  50. package/src/components/work-parts/group-head.svelte.d.ts +5 -2
  51. package/src/editor/editor-toolbar.svelte +223 -193
  52. package/src/editor/editor.svelte +56 -38
  53. package/src/editor/image-toolbar.svelte +99 -37
  54. package/src/editor/image-toolbar.svelte.d.ts +4 -5
  55. package/src/editor/url-dialog.svelte +96 -0
  56. package/src/editor/url-dialog.svelte.d.ts +15 -0
  57. package/src/effects/theme-toggle/ThemeToggle.css +8 -8
  58. package/src/effects/theme-toggle/ThemeToggle.svelte +20 -6
  59. package/src/inbox/thread-inbox.svelte +9 -1
  60. package/src/lib/live-region.svelte +12 -4
  61. package/src/lib/live-region.svelte.d.ts +5 -1
  62. package/src/lib/sliding-pill.ts +3 -2
  63. package/src/record/info-list.svelte +33 -59
  64. package/src/record/measure-clamp.ts +31 -0
  65. package/src/record/record-shell.svelte +38 -39
  66. package/src/record/record-timeline.svelte +10 -5
  67. package/src/record/steps-rail.svelte +50 -36
  68. package/src/record/where-we-are.svelte +5 -19
  69. package/src/shadcn/button-group/button-group-separator.svelte +23 -0
  70. package/src/shadcn/button-group/button-group-separator.svelte.d.ts +13 -0
  71. package/src/shadcn/button-group/button-group-text.svelte +28 -0
  72. package/src/shadcn/button-group/button-group-text.svelte.d.ts +11 -0
  73. package/src/shadcn/button-group/button-group.svelte +46 -0
  74. package/src/shadcn/button-group/button-group.svelte.d.ts +16 -0
  75. package/src/shadcn/button-group/index.ts +15 -0
  76. package/src/shadcn/checkbox/checkbox-box.ts +4 -0
  77. package/src/shadcn/checkbox/checkbox.svelte +2 -4
  78. package/src/shadcn/drawer/drawer-close.svelte +7 -0
  79. package/src/shadcn/drawer/drawer-close.svelte.d.ts +4 -0
  80. package/src/shadcn/drawer/drawer-content.svelte +36 -0
  81. package/src/shadcn/drawer/drawer-content.svelte.d.ts +10 -0
  82. package/src/shadcn/drawer/drawer-description.svelte +17 -0
  83. package/src/shadcn/drawer/drawer-description.svelte.d.ts +4 -0
  84. package/src/shadcn/drawer/drawer-footer.svelte +20 -0
  85. package/src/shadcn/drawer/drawer-footer.svelte.d.ts +5 -0
  86. package/src/shadcn/drawer/drawer-header.svelte +23 -0
  87. package/src/shadcn/drawer/drawer-header.svelte.d.ts +5 -0
  88. package/src/shadcn/drawer/drawer-nested.svelte +12 -0
  89. package/src/shadcn/drawer/drawer-nested.svelte.d.ts +3 -0
  90. package/src/shadcn/drawer/drawer-overlay.svelte +20 -0
  91. package/src/shadcn/drawer/drawer-overlay.svelte.d.ts +4 -0
  92. package/src/shadcn/drawer/drawer-portal.svelte +7 -0
  93. package/src/shadcn/drawer/drawer-portal.svelte.d.ts +3 -0
  94. package/src/shadcn/drawer/drawer-title.svelte +17 -0
  95. package/src/shadcn/drawer/drawer-title.svelte.d.ts +4 -0
  96. package/src/shadcn/drawer/drawer-trigger.svelte +7 -0
  97. package/src/shadcn/drawer/drawer-trigger.svelte.d.ts +4 -0
  98. package/src/shadcn/drawer/drawer.svelte +12 -0
  99. package/src/shadcn/drawer/drawer.svelte.d.ts +3 -0
  100. package/src/shadcn/drawer/index.ts +38 -0
  101. package/src/shadcn/input-group/index.ts +22 -0
  102. package/src/shadcn/input-group/input-group-addon.svelte +51 -0
  103. package/src/shadcn/input-group/input-group-addon.svelte.d.ts +18 -0
  104. package/src/shadcn/input-group/input-group-button.svelte +49 -0
  105. package/src/shadcn/input-group/input-group-button.svelte.d.ts +18 -0
  106. package/src/shadcn/input-group/input-group-input.svelte +20 -0
  107. package/src/shadcn/input-group/input-group-input.svelte.d.ts +11 -0
  108. package/src/shadcn/input-group/input-group-text.svelte +19 -0
  109. package/src/shadcn/input-group/input-group-text.svelte.d.ts +5 -0
  110. package/src/shadcn/input-group/input-group-textarea.svelte +20 -0
  111. package/src/shadcn/input-group/input-group-textarea.svelte.d.ts +3 -0
  112. package/src/shadcn/input-group/input-group.svelte +24 -0
  113. package/src/shadcn/input-group/input-group.svelte.d.ts +5 -0
  114. package/src/shadcn/item/index.ts +34 -0
  115. package/src/shadcn/item/item-actions.svelte +20 -0
  116. package/src/shadcn/item/item-actions.svelte.d.ts +5 -0
  117. package/src/shadcn/item/item-content.svelte +23 -0
  118. package/src/shadcn/item/item-content.svelte.d.ts +5 -0
  119. package/src/shadcn/item/item-description.svelte +23 -0
  120. package/src/shadcn/item/item-description.svelte.d.ts +5 -0
  121. package/src/shadcn/item/item-footer.svelte +20 -0
  122. package/src/shadcn/item/item-footer.svelte.d.ts +5 -0
  123. package/src/shadcn/item/item-group.svelte +21 -0
  124. package/src/shadcn/item/item-group.svelte.d.ts +5 -0
  125. package/src/shadcn/item/item-header.svelte +20 -0
  126. package/src/shadcn/item/item-header.svelte.d.ts +5 -0
  127. package/src/shadcn/item/item-media.svelte +42 -0
  128. package/src/shadcn/item/item-media.svelte.d.ts +17 -0
  129. package/src/shadcn/item/item-separator.svelte +19 -0
  130. package/src/shadcn/item/item-separator.svelte.d.ts +13 -0
  131. package/src/shadcn/item/item-title.svelte +20 -0
  132. package/src/shadcn/item/item-title.svelte.d.ts +5 -0
  133. package/src/shadcn/item/item.svelte +61 -0
  134. package/src/shadcn/item/item.svelte.d.ts +28 -0
  135. package/src/shadcn/native-select/index.ts +13 -0
  136. package/src/shadcn/native-select/native-select-opt-group.svelte +20 -0
  137. package/src/shadcn/native-select/native-select-opt-group.svelte.d.ts +5 -0
  138. package/src/shadcn/native-select/native-select-option.svelte +20 -0
  139. package/src/shadcn/native-select/native-select-option.svelte.d.ts +5 -0
  140. package/src/shadcn/native-select/native-select.svelte +45 -0
  141. package/src/shadcn/native-select/native-select.svelte.d.ts +8 -0
  142. package/src/shadcn/pagination/index.ts +31 -0
  143. package/src/shadcn/pagination/pagination-content.svelte +20 -0
  144. package/src/shadcn/pagination/pagination-content.svelte.d.ts +5 -0
  145. package/src/shadcn/pagination/pagination-ellipsis.svelte +22 -0
  146. package/src/shadcn/pagination/pagination-ellipsis.svelte.d.ts +5 -0
  147. package/src/shadcn/pagination/pagination-item.svelte +14 -0
  148. package/src/shadcn/pagination/pagination-item.svelte.d.ts +5 -0
  149. package/src/shadcn/pagination/pagination-link.svelte +41 -0
  150. package/src/shadcn/pagination/pagination-link.svelte.d.ts +9 -0
  151. package/src/shadcn/pagination/pagination-next-button.svelte +34 -0
  152. package/src/shadcn/pagination/pagination-next-button.svelte.d.ts +4 -0
  153. package/src/shadcn/pagination/pagination-next.svelte +26 -0
  154. package/src/shadcn/pagination/pagination-next.svelte.d.ts +4 -0
  155. package/src/shadcn/pagination/pagination-prev-button.svelte +34 -0
  156. package/src/shadcn/pagination/pagination-prev-button.svelte.d.ts +4 -0
  157. package/src/shadcn/pagination/pagination-previous.svelte +30 -0
  158. package/src/shadcn/pagination/pagination-previous.svelte.d.ts +4 -0
  159. package/src/shadcn/pagination/pagination.svelte +27 -0
  160. package/src/shadcn/pagination/pagination.svelte.d.ts +4 -0
  161. package/src/slides/Controls.svelte +39 -36
  162. package/src/slides/Deck.svelte +11 -8
  163. package/src/thread/thread.svelte +15 -8
  164. package/src/work/detail/task-detail.svelte +1 -1
  165. package/src/work/gantt/gantt-task-row.svelte +3 -6
  166. package/src/work/inbox/inbox-card.svelte +7 -5
  167. package/src/work/inbox/inbox-trace.svelte +14 -10
  168. package/src/work/kanban-board.svelte +40 -25
  169. package/src/work/kanban-hidden-rail.svelte +67 -65
  170. package/src/work/task-list.svelte +145 -124
  171. package/src/work/task-list.svelte.test.ts +5 -2
  172. package/src/work/time/time-tracker.svelte +35 -10
@@ -2,61 +2,124 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import { Toolbar } from "bits-ui";
6
+ import * as Popover from "../shadcn/popover/index.js";
7
+ import * as Tooltip from "../shadcn/tooltip/index.js";
5
8
  import { Trash, TextAlignLeft, TextAlignCenter, TextAlignRight } from "phosphor-svelte";
6
9
 
7
10
  // =========================
8
11
  // TYPES
9
12
  // =========================
10
13
  interface Props {
11
- pos: { top: number; left: number };
14
+ /** The image the toolbar floats over. */
15
+ anchor: HTMLElement | null;
16
+ open: boolean;
12
17
  onAlign: (align: "left" | "center" | "right") => void;
13
18
  onDelete: () => void;
14
19
  }
15
20
 
21
+ type Tool = {
22
+ label: string;
23
+ icon: typeof Trash;
24
+ destructive?: boolean;
25
+ run: () => void;
26
+ };
27
+
16
28
  // =========================
17
29
  // PROPS
18
30
  // =========================
19
- let { pos, onAlign, onDelete }: Props = $props();
31
+ let { anchor, open = $bindable(), onAlign, onDelete }: Props = $props();
32
+
33
+ // =========================
34
+ // CONSTANTS
35
+ // =========================
36
+ const ALIGN: Tool[] = [
37
+ { label: "Align left", icon: TextAlignLeft, run: () => onAlign("left") },
38
+ { label: "Align center", icon: TextAlignCenter, run: () => onAlign("center") },
39
+ { label: "Align right", icon: TextAlignRight, run: () => onAlign("right") },
40
+ ];
41
+
42
+ // =========================
43
+ // STATE
44
+ // =========================
45
+ let content = $state<HTMLElement | null>(null);
46
+
47
+ // =========================
48
+ // FUNCTIONS
49
+ // =========================
50
+ /** Esc from inside the toolbar goes back to the text, which still holds the image selected. */
51
+ function returnToText(e: Event) {
52
+ e.preventDefault();
53
+ const active = document.activeElement;
54
+ if (!active || active === document.body || content?.contains(active)) {
55
+ anchor?.closest<HTMLElement>("[contenteditable='true']")?.focus();
56
+ }
57
+ }
20
58
  </script>
21
59
 
22
60
  <!-- =========================== -->
23
61
  <!-- MARKUP -->
24
62
  <!-- =========================== -->
25
- <div class="image-toolbar" style="top: {pos.top}px; left: {pos.left}px;" role="toolbar">
26
- <button
27
- type="button"
28
- class="image-toolbar-btn"
29
- title="Align left"
30
- onclick={() => onAlign("left")}
31
- >
32
- <TextAlignLeft aria-hidden="true" size={14} />
33
- </button>
34
- <button
35
- type="button"
36
- class="image-toolbar-btn"
37
- title="Align center"
38
- onclick={() => onAlign("center")}
39
- >
40
- <TextAlignCenter aria-hidden="true" size={14} />
41
- </button>
42
- <button
43
- type="button"
44
- class="image-toolbar-btn"
45
- title="Align right"
46
- onclick={() => onAlign("right")}
47
- >
48
- <TextAlignRight aria-hidden="true" size={14} />
49
- </button>
50
- <div class="image-toolbar-divider"></div>
51
- <button
52
- type="button"
53
- class="image-toolbar-btn destructive"
54
- title="Delete image"
55
- onclick={onDelete}
63
+ {#snippet tool(t: Tool)}
64
+ <Tooltip.Root>
65
+ <Tooltip.Trigger aria-label={t.label} onclick={t.run}>
66
+ {#snippet child({ props: trigger })}
67
+ <Toolbar.Button {...trigger}>
68
+ {#snippet child({ props })}
69
+ <button {...props} class="image-toolbar-btn" class:destructive={t.destructive}>
70
+ <t.icon aria-hidden="true" size={14} />
71
+ </button>
72
+ {/snippet}
73
+ </Toolbar.Button>
74
+ {/snippet}
75
+ </Tooltip.Trigger>
76
+ <Tooltip.Content>{t.label}</Tooltip.Content>
77
+ </Tooltip.Root>
78
+ {/snippet}
79
+
80
+ <!-- Not portaled, so Tab from the text reaches it next. Focus stays in the text when it opens. -->
81
+ <Popover.Root bind:open>
82
+ <Popover.Content
83
+ bind:ref={content}
84
+ customAnchor={anchor}
85
+ side="top"
86
+ sideOffset={4}
87
+ trapFocus={false}
88
+ portalProps={{ disabled: true }}
89
+ onOpenAutoFocus={(e) => e.preventDefault()}
90
+ onCloseAutoFocus={returnToText}
56
91
  >
57
- <Trash aria-hidden="true" size={14} />
58
- </button>
59
- </div>
92
+ {#snippet child({ props, wrapperProps })}
93
+ <div {...wrapperProps}>
94
+ <!-- Esc closes the toolbar itself here; left to Bits, the first one only shuts the tooltip. -->
95
+ <Toolbar.Root
96
+ {...props}
97
+ aria-label="Image"
98
+ onkeydown={(e: KeyboardEvent) => {
99
+ if (e.key === "Escape") open = false;
100
+ }}
101
+ >
102
+ {#snippet child({ props: root })}
103
+ <Tooltip.Provider>
104
+ <div {...root} class="image-toolbar">
105
+ {#each ALIGN as t (t.label)}
106
+ {@render tool(t)}
107
+ {/each}
108
+ <div class="image-toolbar-divider"></div>
109
+ {@render tool({
110
+ label: "Delete image",
111
+ icon: Trash,
112
+ destructive: true,
113
+ run: onDelete,
114
+ })}
115
+ </div>
116
+ </Tooltip.Provider>
117
+ {/snippet}
118
+ </Toolbar.Root>
119
+ </div>
120
+ {/snippet}
121
+ </Popover.Content>
122
+ </Popover.Root>
60
123
 
61
124
  <!-- =========================== -->
62
125
  <!-- STYLE -->
@@ -67,7 +130,6 @@
67
130
  @layer components {
68
131
  /* ---- IMAGE TOOLBAR ---- */
69
132
  .image-toolbar {
70
- position: absolute;
71
133
  z-index: 40;
72
134
  display: flex;
73
135
  align-items: center;
@@ -1,11 +1,10 @@
1
1
  interface Props {
2
- pos: {
3
- top: number;
4
- left: number;
5
- };
2
+ /** The image the toolbar floats over. */
3
+ anchor: HTMLElement | null;
4
+ open: boolean;
6
5
  onAlign: (align: "left" | "center" | "right") => void;
7
6
  onDelete: () => void;
8
7
  }
9
- declare const $$Component: import("svelte").Component<Props, {}, "">;
8
+ declare const $$Component: import("svelte").Component<Props, {}, "open">;
10
9
  type $$Component = ReturnType<typeof $$Component>;
11
10
  export default $$Component;
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Button } from "../shadcn/button/index.js";
6
+ import * as Dialog from "../shadcn/dialog/index.js";
7
+ import { SimpleField } from "../shadcn/field/index.js";
8
+ import { Input } from "../shadcn/input/index.js";
9
+
10
+ // =========================
11
+ // TYPES
12
+ // =========================
13
+ interface Props {
14
+ open: boolean;
15
+ heading: string;
16
+ label: string;
17
+ placeholder: string;
18
+ /** Adds an optional second field under the link, with this label. */
19
+ titleLabel?: string;
20
+ /** Inserts from the fields, then answers the error to show, or null once it's done. */
21
+ onSubmit: (url: string, title: string) => string | null;
22
+ /** Focus goes back to the text, whichever way the dialog closed. */
23
+ onClosed: () => void;
24
+ }
25
+
26
+ // =========================
27
+ // PROPS
28
+ // =========================
29
+ let {
30
+ open = $bindable(),
31
+ heading,
32
+ label,
33
+ placeholder,
34
+ titleLabel,
35
+ onSubmit,
36
+ onClosed,
37
+ }: Props = $props();
38
+
39
+ // =========================
40
+ // STATE
41
+ // =========================
42
+ const uid = $props.id();
43
+ let url = $state("");
44
+ let title = $state("");
45
+ let error = $state<string | null>(null);
46
+
47
+ // =========================
48
+ // FUNCTIONS
49
+ // =========================
50
+ function submit(e: SubmitEvent) {
51
+ e.preventDefault();
52
+ error = onSubmit(url.trim(), title.trim());
53
+ if (!error) open = false;
54
+ }
55
+
56
+ function reset(e: Event) {
57
+ e.preventDefault();
58
+ url = "";
59
+ title = "";
60
+ error = null;
61
+ onClosed();
62
+ }
63
+ </script>
64
+
65
+ <!-- =========================== -->
66
+ <!-- MARKUP -->
67
+ <!-- =========================== -->
68
+ <Dialog.Root bind:open>
69
+ <Dialog.Content onCloseAutoFocus={reset}>
70
+ <form class="contents" novalidate onsubmit={submit}>
71
+ <Dialog.Header>
72
+ <Dialog.Title>{heading}</Dialog.Title>
73
+ </Dialog.Header>
74
+ <Dialog.Body>
75
+ <SimpleField {label} for="{uid}-url" error={error ?? undefined}>
76
+ <Input
77
+ id="{uid}-url"
78
+ type="url"
79
+ bind:value={url}
80
+ {placeholder}
81
+ oninput={() => (error = null)}
82
+ />
83
+ </SimpleField>
84
+ {#if titleLabel}
85
+ <SimpleField label={titleLabel} for="{uid}-title">
86
+ <Input id="{uid}-title" bind:value={title} />
87
+ </SimpleField>
88
+ {/if}
89
+ </Dialog.Body>
90
+ <Dialog.Footer>
91
+ <Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
92
+ <Button type="submit">Insert</Button>
93
+ </Dialog.Footer>
94
+ </form>
95
+ </Dialog.Content>
96
+ </Dialog.Root>
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ open: boolean;
3
+ heading: string;
4
+ label: string;
5
+ placeholder: string;
6
+ /** Adds an optional second field under the link, with this label. */
7
+ titleLabel?: string;
8
+ /** Inserts from the fields, then answers the error to show, or null once it's done. */
9
+ onSubmit: (url: string, title: string) => string | null;
10
+ /** Focus goes back to the text, whichever way the dialog closed. */
11
+ onClosed: () => void;
12
+ }
13
+ declare const $$Component: import("svelte").Component<Props, {}, "open">;
14
+ type $$Component = ReturnType<typeof $$Component>;
15
+ export default $$Component;
@@ -86,7 +86,7 @@
86
86
  transition: opacity 500ms;
87
87
  }
88
88
 
89
- .theme-toggle-slide[data-state="dark"] {
89
+ .theme-toggle-slide[data-mode="dark"] {
90
90
  --tt-glow-x: 75%;
91
91
  }
92
92
 
@@ -110,17 +110,17 @@
110
110
  color: var(--theme-toggle-moon, var(--color-muted-foreground));
111
111
  }
112
112
 
113
- .theme-toggle-slide[data-state="light"] .theme-toggle-slide__icon--sun,
114
- .theme-toggle-slide[data-state="dark"] .theme-toggle-slide__icon--moon {
113
+ .theme-toggle-slide[data-mode="light"] .theme-toggle-slide__icon--sun,
114
+ .theme-toggle-slide[data-mode="dark"] .theme-toggle-slide__icon--moon {
115
115
  opacity: 0.7;
116
116
  transform: scale(1);
117
117
  }
118
118
 
119
- .theme-toggle-slide[data-state="light"] .theme-toggle-slide__icon--sun {
119
+ .theme-toggle-slide[data-mode="light"] .theme-toggle-slide__icon--sun {
120
120
  animation: theme-toggle-spin 8s linear infinite;
121
121
  }
122
122
 
123
- .theme-toggle-slide[data-state="dark"] .theme-toggle-slide__icon--moon {
123
+ .theme-toggle-slide[data-mode="dark"] .theme-toggle-slide__icon--moon {
124
124
  animation: theme-toggle-wiggle 2s ease-in-out infinite;
125
125
  }
126
126
 
@@ -143,7 +143,7 @@
143
143
  background-color 500ms;
144
144
  }
145
145
 
146
- .theme-toggle-slide[data-state="dark"] .theme-toggle-slide__thumb {
146
+ .theme-toggle-slide[data-mode="dark"] .theme-toggle-slide__thumb {
147
147
  --tt-thumb-color: var(--theme-toggle-thumb-dark, var(--color-primary));
148
148
  transform: translateX(var(--tt-thumb));
149
149
  }
@@ -174,12 +174,12 @@
174
174
  transform: scale(0.9) rotate(-90deg);
175
175
  }
176
176
 
177
- .theme-toggle-slide[data-state="dark"] .theme-toggle-slide__mark--sun {
177
+ .theme-toggle-slide[data-mode="dark"] .theme-toggle-slide__mark--sun {
178
178
  opacity: 0;
179
179
  transform: scale(0.9) rotate(90deg);
180
180
  }
181
181
 
182
- .theme-toggle-slide[data-state="dark"] .theme-toggle-slide__mark--moon {
182
+ .theme-toggle-slide[data-mode="dark"] .theme-toggle-slide__mark--moon {
183
183
  opacity: 1;
184
184
  transform: scale(1) rotate(0);
185
185
  }
@@ -4,6 +4,7 @@
4
4
  // =========================
5
5
  import "./ThemeToggle.css";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { Switch } from "bits-ui";
7
8
  import { Monitor, Moon, Sun } from "phosphor-svelte";
8
9
  import { revealTheme } from "../../lib/theme-transition.js";
9
10
 
@@ -55,6 +56,8 @@
55
56
  let text = $derived(
56
57
  label ?? (mode ? `Theme: ${mode}` : isDark ? "Switch to light mode" : "Switch to dark mode"),
57
58
  );
59
+ // A switch reads its own on or off, so its name stays put instead of naming the next press.
60
+ let slideText = $derived(label ?? "Dark mode");
58
61
 
59
62
  // =========================
60
63
  // FUNCTIONS
@@ -63,6 +66,14 @@
63
66
  revealTheme(event, () => onToggle(event), { skip: skipTransition });
64
67
  }
65
68
 
69
+ // bits-ui flips a switch on keydown with no click, and `onToggle` wants a MouseEvent, so the
70
+ // key becomes the click a plain button would have fired. preventDefault stops bits-ui's own.
71
+ function clickOnKey(event: KeyboardEvent & { currentTarget: HTMLButtonElement }) {
72
+ if (event.key !== "Enter" && event.key !== " ") return;
73
+ event.preventDefault();
74
+ event.currentTarget.click();
75
+ }
76
+
66
77
  function iconClass(show: boolean, tilt: "rotate-90" | "-rotate-90") {
67
78
  return `absolute size-4 transition-[opacity,scale,rotate] duration-300 ${
68
79
  show ? "scale-100 rotate-0 opacity-100" : `scale-90 ${tilt} opacity-0`
@@ -74,12 +85,15 @@
74
85
  <!-- MARKUP -->
75
86
  <!-- =========================== -->
76
87
  {#if style === "slide"}
77
- <button
78
- type="button"
88
+ <!-- WHY a no-op setter: the theme is the caller's, so `onclick` hands it the MouseEvent and
89
+ the switch only mirrors `active`. -->
90
+ <Switch.Root
91
+ bind:checked={() => active === "dark", () => {}}
79
92
  onclick={handleClick}
80
- aria-label={text}
93
+ onkeydown={clickOnKey}
94
+ aria-label={slideText}
81
95
  {title}
82
- data-state={active === "dark" ? "dark" : "light"}
96
+ data-mode={active === "dark" ? "dark" : "light"}
83
97
  class={cn("theme-toggle-slide", size === "default" && "theme-toggle-slide--lg", className)}
84
98
  >
85
99
  <span class="theme-toggle-slide__glow" aria-hidden="true"></span>
@@ -104,8 +118,8 @@
104
118
  class="theme-toggle-slide__mark theme-toggle-slide__mark--moon"
105
119
  />
106
120
  </span>
107
- <span class="sr-only">{text}</span>
108
- </button>
121
+ <span class="sr-only">{slideText}</span>
122
+ </Switch.Root>
109
123
  {:else}
110
124
  <button
111
125
  onclick={handleClick}
@@ -139,6 +139,8 @@
139
139
  let labels = $derived(resolveInboxLabels(labelOverrides));
140
140
  let filter = $state<InboxFilter>({ ...NO_FILTER });
141
141
  let snoozing = $state(false);
142
+ const uid = $props.id();
143
+ const snoozeChoicesId = `${uid}-snooze`;
142
144
  let pane = $state<"rail" | "detail">("rail");
143
145
  let root = $state<HTMLDivElement>();
144
146
  let cards = $state<Record<string, ReturnType<typeof InboxCard> | null>>({});
@@ -472,6 +474,7 @@
472
474
  variant="ghost"
473
475
  onclick={snoozeKey}
474
476
  aria-expanded={snoozing}
477
+ aria-controls={snoozeChoicesId}
475
478
  aria-keyshortcuts="h"
476
479
  >
477
480
  <BellSlash aria-hidden="true" />{labels.snooze}
@@ -496,7 +499,12 @@
496
499
  </DetailHeader>
497
500
 
498
501
  {#if snoozing}
499
- <div class="flex flex-wrap gap-2" role="group" aria-label={labels.snooze}>
502
+ <div
503
+ id={snoozeChoicesId}
504
+ class="flex flex-wrap gap-2"
505
+ role="group"
506
+ aria-label={labels.snooze}
507
+ >
500
508
  {#each snoozeChoices(new Date()) as choice, index (choice.id)}
501
509
  <Button size="sm" variant="outline" onclick={() => snooze(choice.until)}>
502
510
  <Kbd>{index + 1}</Kbd>{snoozeLabel(choice.id)}
@@ -1,14 +1,22 @@
1
1
  <script lang="ts">
2
2
  import { tick } from "svelte";
3
3
 
4
- let message = $state("");
4
+ interface Props {
5
+ /** Drives the region from state: whatever it holds is what gets read. Leave it out to use `announce`. */
6
+ message?: string;
7
+ }
8
+
9
+ let { message }: Props = $props();
10
+
11
+ let spoken = $state("");
5
12
 
6
13
  /** Reads text out politely. Clearing first makes the same words read again. */
7
14
  export async function announce(text: string): Promise<void> {
8
- message = "";
15
+ spoken = "";
9
16
  await tick();
10
- message = text;
17
+ spoken = text;
11
18
  }
12
19
  </script>
13
20
 
14
- <div class="sr-only" role="status" aria-live="polite">{message}</div>
21
+ <!-- A span, so it can sit inside a button. sr-only takes it out of the flow either way. -->
22
+ <span class="sr-only" role="status" aria-live="polite">{message ?? spoken}</span>
@@ -1,4 +1,8 @@
1
- declare const $$Component: import("svelte").Component<Record<string, never>, {
1
+ interface Props {
2
+ /** Drives the region from state: whatever it holds is what gets read. Leave it out to use `announce`. */
3
+ message?: string;
4
+ }
5
+ declare const $$Component: import("svelte").Component<Props, {
2
6
  announce: (text: string) => Promise<void>;
3
7
  }, "">;
4
8
  type $$Component = ReturnType<typeof $$Component>;
@@ -6,11 +6,12 @@ import type { Attachment } from "svelte/attachments";
6
6
  // `parentElement`, so any strip that renders one span gets the motion.
7
7
 
8
8
  // WHY measure: the active item's width is its label. The selector covers the
9
- // three ways a strip marks it: a tab, a nav link, a toggle.
9
+ // ways a strip marks it: a tab, a nav link, a pressed button, a bits-ui toggle group item.
10
10
 
11
11
  // Per transitions.dev `tabs-sliding`: first paint and resizes write the
12
12
  // position with the transition off, so the pill snaps in instead of flying.
13
- const ACTIVE = '[data-state="active"],[aria-current="page"],[aria-pressed="true"]';
13
+ const ACTIVE =
14
+ '[data-state="active"],[aria-current="page"],[aria-pressed="true"],[data-state="on"]';
14
15
 
15
16
  export const slidingPill: Attachment<HTMLElement> = (pill) => {
16
17
  const list = pill.parentElement;
@@ -3,15 +3,14 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import type { Snippet } from "svelte";
6
- import { Check, CaretUpDown } from "phosphor-svelte";
6
+ import { CaretUpDown } from "phosphor-svelte";
7
7
  import { cn } from "../lib/utils.js";
8
- import { Button } from "../shadcn/button/index.js";
8
+ import { Button, buttonVariants } from "../shadcn/button/index.js";
9
9
  import { Input } from "../shadcn/input/index.js";
10
10
  import { Textarea } from "../shadcn/textarea/index.js";
11
11
  import { Spinner } from "../shadcn/spinner/index.js";
12
12
  import * as Select from "../shadcn/select/index.js";
13
- import * as Popover from "../shadcn/popover/index.js";
14
- import * as Command from "../shadcn/command/index.js";
13
+ import { Picker } from "../components/picker/index.js";
15
14
  import { TagsInput } from "../components/tags-input/index.js";
16
15
  import StatusBadge from "../status/status-badge.svelte";
17
16
  import { resolveLabels, type InfoRow, type RecordLabels } from "./types.js";
@@ -58,7 +57,6 @@
58
57
  let draft = $state("");
59
58
  let saving = $state<string | null>(null);
60
59
  let failed = $state<{ key: string; text: string } | null>(null);
61
- let pickerOpen = $state<string | null>(null);
62
60
 
63
61
  // =========================
64
62
  // DERIVED
@@ -130,7 +128,6 @@
130
128
  }
131
129
 
132
130
  function pick(row: InfoRow, value: string) {
133
- pickerOpen = null;
134
131
  const current = typeof row.editValue === "string" ? row.editValue : null;
135
132
  if (value !== current) void commit(row, value, false);
136
133
  }
@@ -197,58 +194,34 @@
197
194
  {/snippet}
198
195
 
199
196
  {#snippet list(row: InfoRow, key: string, multi: boolean)}
200
- {@const options = row.options ?? []}
201
- {@const current = multi
202
- ? Array.isArray(row.editValue)
203
- ? row.editValue
204
- : []
205
- : [typeof row.editValue === "string" ? row.editValue : ""]}
206
- <Popover.Root open={pickerOpen === key} onOpenChange={(open) => (pickerOpen = open ? key : null)}>
207
- <Popover.Trigger>
208
- {#snippet child({ props })}
209
- <Button
210
- {...props}
211
- variant="text"
212
- size="sm"
213
- class={OPENER}
214
- aria-label={t.edit(row.label)}
215
- disabled={saving === key}
216
- >
217
- {@render display(row)}
218
- {#if saving === key}
219
- <Spinner class="size-3.5 text-muted-foreground" />
220
- {:else if !multi}
221
- <CaretUpDown aria-hidden="true" class="size-3.5 shrink-0 opacity-60" />
222
- {/if}
223
- </Button>
224
- {/snippet}
225
- </Popover.Trigger>
226
- <Popover.Content class="w-64 p-0" align="start">
227
- <Command.Root>
228
- <Command.Input placeholder={t.search} />
229
- <Command.List class="max-h-72 overflow-y-auto">
230
- <Command.Empty>{t.noMatch}</Command.Empty>
231
- <Command.Group>
232
- {#each options as option (option.value)}
233
- <Command.Item
234
- value={option.label}
235
- onSelect={() => (multi ? toggle(row, option.value) : pick(row, option.value))}
236
- >
237
- <Check
238
- aria-hidden="true"
239
- class={cn(
240
- "size-3.5",
241
- current.includes(option.value) ? "opacity-100" : "opacity-0",
242
- )}
243
- />
244
- {option.label}
245
- </Command.Item>
246
- {/each}
247
- </Command.Group>
248
- </Command.List>
249
- </Command.Root>
250
- </Popover.Content>
251
- </Popover.Root>
197
+ <Picker
198
+ options={row.options ?? []}
199
+ value={multi
200
+ ? Array.isArray(row.editValue)
201
+ ? row.editValue
202
+ : []
203
+ : typeof row.editValue === "string"
204
+ ? row.editValue
205
+ : null}
206
+ onSelect={(value) => (multi ? toggle(row, value) : pick(row, value))}
207
+ triggerClass={cn(buttonVariants({ variant: "text", size: "sm" }), OPENER)}
208
+ searchable
209
+ searchPlaceholder={t.search}
210
+ emptyLabel={t.noMatch}
211
+ width="lg"
212
+ disabled={saving === key}
213
+ >
214
+ {#snippet trigger()}
215
+ <!-- Named by its text: the field, then the value it shows (WCAG 2.5.3, label in name). -->
216
+ <span class="sr-only">{t.edit(row.label)}</span>
217
+ {@render display(row)}
218
+ {#if saving === key}
219
+ <Spinner class="size-3.5 text-muted-foreground" />
220
+ {:else if !multi}
221
+ <CaretUpDown aria-hidden="true" class="size-3.5 shrink-0 opacity-60" />
222
+ {/if}
223
+ {/snippet}
224
+ </Picker>
252
225
  {/snippet}
253
226
 
254
227
  {#if layout === "row" && (shown.length > 0 || trailing)}
@@ -412,9 +385,10 @@
412
385
  variant="text"
413
386
  size="sm"
414
387
  class={cn(OPENER, isEmpty(row) && "text-muted-foreground")}
415
- aria-label={t.edit(row.label)}
416
388
  onclick={() => begin(row)}
417
389
  >
390
+ <!-- Named by its text: the field, then the value it shows (WCAG 2.5.3, label in name). -->
391
+ <span class="sr-only">{t.edit(row.label)}</span>
418
392
  {@render display(row)}
419
393
  </Button>
420
394
  {/if}
@@ -0,0 +1,31 @@
1
+ import { untrack } from "svelte";
2
+ import type { Attachment } from "svelte/attachments";
3
+
4
+ /** Reports whether a line-clamped box cuts its text, so "More" shows only then. */
5
+
6
+ // WHY measure: a note that fits two lines at 1280px overflows at 390px.
7
+
8
+ // WHY untracked: an open box keeps its last answer, so a fold never remounts the toggle under focus.
9
+ export function measureClamp(
10
+ expanded: () => boolean,
11
+ onCut: (cut: boolean) => void,
12
+ ): Attachment<HTMLElement> {
13
+ return (el) => {
14
+ const measure = () =>
15
+ untrack(() => {
16
+ if (!expanded()) onCut(el.scrollHeight > el.clientHeight + 1);
17
+ });
18
+ measure();
19
+ const observer = new ResizeObserver(measure);
20
+ observer.observe(el);
21
+ // A web font landing rewraps the text in a box that keeps its height, and the observer stays quiet.
22
+ let live = true;
23
+ void document.fonts?.ready.then(() => live && measure());
24
+ document.fonts?.addEventListener("loadingdone", measure);
25
+ return () => {
26
+ live = false;
27
+ observer.disconnect();
28
+ document.fonts?.removeEventListener("loadingdone", measure);
29
+ };
30
+ };
31
+ }