@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
@@ -23,6 +23,7 @@
23
23
  import { resolveEdgeFade } from "./edge-fade.js";
24
24
  import Figure from "./figure.svelte";
25
25
  import HoverFocus from "./hover-focus.svelte";
26
+ import { dualAxis } from "./dual-axis.js";
26
27
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
27
28
  import { valueTick } from "./primitives/chart-utils.js";
28
29
  import Reveal from "./reveal.svelte";
@@ -44,6 +45,11 @@
44
45
  key: keyof TData & string;
45
46
  label?: string;
46
47
  color?: string;
48
+ /**
49
+ * `"right"` plots the series on a second value axis with its own scale, for a measure in a
50
+ * different unit or about 10x larger than the rest. It draws as a line, without the fill.
51
+ */
52
+ axis?: "left" | "right";
47
53
  };
48
54
 
49
55
  interface Props {
@@ -68,8 +74,15 @@
68
74
  xTicks?: number;
69
75
  /** A target drawn across the plot, like the deal a cost runs up to. */
70
76
  reference?: ChartReference;
71
- /** The headline while no point is hovered, like a period total. Defaults to the last point. */
72
- resting?: { value: number; label: string };
77
+ /**
78
+ * The headline while no point is hovered, like a period total. Defaults to the last point.
79
+ * `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
80
+ */
81
+ resting?: {
82
+ value?: number;
83
+ values?: Partial<Record<keyof TData & string, number>>;
84
+ label: string;
85
+ };
73
86
  /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
74
87
  smoothing?: Smoothing;
75
88
  /** The chart's accessible name. Its data range and values are read after it. */
@@ -146,11 +159,14 @@
146
159
  key: s.key,
147
160
  label: s.label ?? s.key,
148
161
  color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
162
+ axis: s.axis === "right" ? ("right" as const) : ("left" as const),
149
163
  }))
150
164
  : yKey
151
- ? [{ key: yKey, label: yKey, color }]
165
+ ? [{ key: yKey, label: yKey, color, axis: "left" as const }]
152
166
  : [],
153
167
  );
168
+ const leftSeries = $derived(series.filter((s) => s.axis === "left"));
169
+ const rightSeries = $derived(series.filter((s) => s.axis === "right"));
154
170
  const smoothed = $derived(
155
171
  smoothing
156
172
  ? smoothSeries(
@@ -167,12 +183,41 @@
167
183
  const averageLabel = $derived(mode === "rolling" ? averageName(smoothed.window) : null);
168
184
  const dataMax = $derived.by(() => {
169
185
  if (stacked) {
170
- return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
186
+ return Math.max(
187
+ 0,
188
+ ...data.map((d) => leftSeries.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)),
189
+ );
171
190
  }
172
- return Math.max(0, ...data.flatMap((d) => series.map((s) => Number(d[s.key] ?? 0))));
191
+ return Math.max(0, ...data.flatMap((d) => leftSeries.map((s) => Number(d[s.key] ?? 0))));
173
192
  });
174
193
  // A target above every point would sit off the plot.
175
194
  const yMax = $derived(Math.max(dataMax, reference?.value ?? 0));
195
+ // A right-axis series lives on the chart's one y scale, rescaled so its domain fills the same
196
+ // height; the right axis labels that height in the series' own unit.
197
+ const axes = $derived.by(() => {
198
+ if (rightSeries.length === 0) return null;
199
+ const rightMax = Math.max(
200
+ 0,
201
+ ...data.flatMap((d) => rightSeries.map((s) => Number(d[s.key] ?? 0))),
202
+ );
203
+ return dualAxis(yMax, rightMax);
204
+ });
205
+ const plotKey = (s: { key: string; axis: "left" | "right" }) =>
206
+ s.axis === "right" ? `__hr_right_${s.key}` : s.key;
207
+ const plotData = $derived.by((): TData[] => {
208
+ if (!axes) return data;
209
+ const factor = axes.left.max / axes.right.max;
210
+ return data.map((d) => {
211
+ const row: Record<string, unknown> = { ...d };
212
+ // A gap stays a gap: null times a factor would draw a 0.
213
+ for (const s of rightSeries)
214
+ row[plotKey(s)] = d[s.key] == null ? null : Number(d[s.key]) * factor;
215
+ return row as TData;
216
+ });
217
+ });
218
+ // An axis takes its series' colour only when one series owns it; with two on a side the colour would name one of them.
219
+ const leftTone = $derived(axes && leftSeries.length === 1 ? leftSeries[0].color : undefined);
220
+ const rightTone = $derived(rightSeries.length === 1 ? rightSeries[0].color : undefined);
176
221
  const curve = $derived(smooth ? curveMonotoneX : undefined);
177
222
  const fade = $derived(resolveEdgeFade(edgeFade));
178
223
  const fadeMask = $derived(fade > 0 ? `url(#${uid}-fade)` : undefined);
@@ -206,7 +251,16 @@
206
251
  <!-- WHY: leaving only resets the hover readout; the keyboard path is ChartPlot's. -->
207
252
  <!-- svelte-ignore a11y_no_static_element_interactions -->
208
253
  <div class="hr-area-chart {className ?? ''}" onpointerleave={() => (active = null)}>
209
- {#if resting && !active && series[0]}
254
+ {#if resting?.values && !active}
255
+ <Figure
256
+ series={series
257
+ .filter((s) => resting.values?.[s.key] != null)
258
+ .map((s) => ({ value: Number(resting.values?.[s.key]), label: s.label, color: s.color }))}
259
+ eyebrow={resting.label}
260
+ format={formatValue}
261
+ size="md"
262
+ />
263
+ {:else if resting && resting.value != null && !active && series[0]}
210
264
  <Figure
211
265
  series={[{ value: resting.value, label: series[0].label, color: series[0].color }]}
212
266
  eyebrow={resting.label}
@@ -226,21 +280,43 @@
226
280
  <div class="empty"><p>No data</p></div>
227
281
  {:else}
228
282
  <Chart
229
- {data}
283
+ data={plotData}
230
284
  x={xKey}
231
- y={series[0]?.key}
232
- yDomain={[0, yMax || 1]}
233
- yNice
234
- padding={{ left: 48, bottom: 28, top: 8, right: 12 }}
285
+ y={series[0] ? plotKey(series[0]) : undefined}
286
+ yDomain={[0, axes ? axes.left.max : yMax || 1]}
287
+ yNice={!axes}
288
+ padding={{ left: 48, bottom: 28, top: 8, right: axes ? 48 : 12 }}
235
289
  tooltipContext={{ mode: "quadtree-x" }}
236
290
  motion={DOMAIN_MOTION}
237
291
  bind:context
238
292
  >
239
293
  <Svg aria-hidden="true">
240
- <Axis placement="left" format={(v) => tick(Number(v))} ticks={4} />
294
+ <g class="hr-axis-tone" style:--hr-axis-tone={leftTone}>
295
+ <Axis
296
+ placement="left"
297
+ format={(v) => tick(Number(v))}
298
+ ticks={axes ? axes.left.ticks : 4}
299
+ />
300
+ </g>
301
+ {#if axes}
302
+ <g class="hr-axis-tone" style:--hr-axis-tone={rightTone}>
303
+ <Axis
304
+ placement="right"
305
+ domain={[0, axes.right.max]}
306
+ format={(v) => tick(Number(v))}
307
+ ticks={axes.right.ticks}
308
+ />
309
+ </g>
310
+ {/if}
241
311
  <Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
242
312
  <g class="hr-area-grid">
243
- <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
313
+ <Axis
314
+ placement="left"
315
+ grid
316
+ rule={false}
317
+ ticks={axes ? axes.left.ticks : 4}
318
+ format={() => ""}
319
+ />
244
320
  </g>
245
321
  {#if fade > 0}
246
322
  <EdgeFade id="{uid}-fade" amount={fade} />
@@ -261,17 +337,39 @@
261
337
  <!-- WHY `y1` and not `y`: layerchart's Area has no `y` prop, so every
262
338
  series drew the first series' shape and the gradients stacked on
263
339
  one path. See `Area.shared.svelte.js`. -->
264
- <Area y1={s.key} {curve} fill="url(#{uid}-{i})" mask={fadeMask} />
265
- <Spline y={s.key} {curve} style="stroke: {s.color}; fill: none" stroke-width="2" />
340
+ {#if s.axis === "left"}
341
+ <Area y1={s.key} {curve} fill="url(#{uid}-{i})" mask={fadeMask} />
342
+ {/if}
343
+ <Spline
344
+ y={plotKey(s)}
345
+ {curve}
346
+ style="stroke: {s.color}; fill: none"
347
+ stroke-width="2"
348
+ />
266
349
  {/each}
267
350
  </Reveal>
268
351
  {#snippet segment()}
269
352
  {#each series as s (s.key)}
270
- <Spline y={s.key} {curve} style="stroke: {s.color}; fill: none" stroke-width="2" />
353
+ <Spline
354
+ y={plotKey(s)}
355
+ {curve}
356
+ style="stroke: {s.color}; fill: none"
357
+ stroke-width="2"
358
+ />
271
359
  {/each}
272
360
  {/snippet}
273
361
  </HoverFocus>
274
- <Highlight points lines={{ style: `stroke: url(#${uid}-cross)` }} />
362
+ {#if axes}
363
+ <!-- WHY `data`: without it Highlight places dots from the tooltip's raw values, off the rescaled right series. -->
364
+ <Highlight lines={{ style: `stroke: url(#${uid}-cross)` }} />
365
+ {#if context?.tooltip.data}
366
+ {#each series as s (s.key)}
367
+ <Highlight data={context.tooltip.data} y={plotKey(s)} points={{ fill: s.color }} />
368
+ {/each}
369
+ {/if}
370
+ {:else}
371
+ <Highlight points lines={{ style: `stroke: url(#${uid}-cross)` }} />
372
+ {/if}
275
373
  </Svg>
276
374
  <Tooltip.Root x="data" y={TOOLTIP_TOP} xOffset={TOOLTIP_GAP}>
277
375
  {#snippet children({ data: d })}
@@ -11,6 +11,11 @@ declare function $$render<TData extends Record<string, unknown>>(): {
11
11
  key: keyof TData & string;
12
12
  label?: string;
13
13
  color?: string;
14
+ /**
15
+ * `"right"` plots the series on a second value axis with its own scale, for a measure in a
16
+ * different unit or about 10x larger than the rest. It draws as a line, without the fill.
17
+ */
18
+ axis?: "left" | "right";
14
19
  }[];
15
20
  color?: string;
16
21
  height?: number;
@@ -25,9 +30,13 @@ declare function $$render<TData extends Record<string, unknown>>(): {
25
30
  xTicks?: number;
26
31
  /** A target drawn across the plot, like the deal a cost runs up to. */
27
32
  reference?: ChartReference;
28
- /** The headline while no point is hovered, like a period total. Defaults to the last point. */
33
+ /**
34
+ * The headline while no point is hovered, like a period total. Defaults to the last point.
35
+ * `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
36
+ */
29
37
  resting?: {
30
- value: number;
38
+ value?: number;
39
+ values?: Partial<Record<keyof TData & string, number>>;
31
40
  label: string;
32
41
  };
33
42
  /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
@@ -4,8 +4,8 @@
4
4
  `@heyramzi/ui/charts/dashboard` keeps its card edge without the barrel. */
5
5
 
6
6
  @layer components {
7
- /* Shared chart tokens, held at zero specificity by `:where()`. The reveal pair
8
- is the entrance every chart shares; motion.ts mirrors its duration. */
7
+ /* Shared chart tokens, held at zero specificity by `:where()`. The tooltip portals to
8
+ `body`, outside its chart, so it is listed for its series dots to match the lines. */
9
9
  :where(
10
10
  .hr-area-chart,
11
11
  .hr-bar-chart,
@@ -17,7 +17,8 @@
17
17
  .hr-ts-chart,
18
18
  .hr-donut-chart,
19
19
  .hr-ring,
20
- .hr-gauge
20
+ .hr-gauge,
21
+ .lc-tooltip-container
21
22
  ) {
22
23
  /* Series palette: brand, then hues round the wheel, blue and orange first for colour
23
24
  blindness. Each is pulled 15% toward the text, clearing 3:1 on the card in both themes. */
@@ -43,9 +43,11 @@
43
43
  */
44
44
  import { cn } from "../../lib/utils.js";
45
45
  import RatingChip from "./rating-chip.svelte";
46
+ import Hint from "./hint.svelte";
46
47
  import {
47
48
  Pagination,
48
49
  SortableHead,
50
+ TableSkeleton,
49
51
  paginateRows,
50
52
  totalPages,
51
53
  type SortState,
@@ -127,6 +129,18 @@
127
129
  });
128
130
 
129
131
  const visible = $derived(pageSize ? paginateRows(sorted, page, pageSize) : sorted);
132
+
133
+ // Placeholder rows hold the 40px of a lead cell's padding, touch or not, and the rule sits
134
+ // outside it as before. Each cell keeps its column's padding, rule and breakpoint.
135
+ const loadingCell = "box-content h-10 any-pointer-coarse:h-10";
136
+ const loadingColumns = $derived([
137
+ { width: "w-2/3", class: cn("px-4", loadingCell) },
138
+ ...columns.map((column) => ({
139
+ width: "w-3/4",
140
+ align: column.align === "start" ? ("left" as const) : ("right" as const),
141
+ class: cn(columnClass(column), loadingCell),
142
+ })),
143
+ ]);
130
144
  const opens = $derived(Boolean(rowHref || onRowClick));
131
145
 
132
146
  // Fewer rows than the page you stand on would draw an empty table.
@@ -182,96 +196,82 @@
182
196
  <th
183
197
  scope="col"
184
198
  class={cn(column.align === "start" ? dataThClass : dataThNumClass, columnClass(column))}
185
- title={column.hint}
186
199
  >
187
- {column.label}
200
+ <Hint text={column.hint}>{column.label}</Hint>
188
201
  </th>
189
202
  {/if}
190
203
  {/each}
191
204
  </tr>
192
205
  </thead>
193
- <tbody>
194
- {#if loading}
195
- {#each { length: LOADING_ROWS }, index (index)}
196
- <tr class={dataTrClass} aria-hidden="true">
197
- <td class={cn(dataTdClass, "py-3")}>
198
- <span class="block h-4 w-2/3 animate-pulse rounded bg-muted"></span>
199
- </td>
200
- {#each columns as column (column.key)}
201
- <td class={cn(dataTdNumClass, columnClass(column), "align-middle")}>
202
- <span
203
- class={cn(
204
- "block h-4 w-3/4 animate-pulse rounded bg-muted",
205
- column.align !== "start" && "ml-auto",
206
- )}
207
- ></span>
208
- </td>
209
- {/each}
206
+ {#if loading}
207
+ <TableSkeleton rows={LOADING_ROWS} columns={loadingColumns} rowClass={dataTrClass} />
208
+ {:else}
209
+ <tbody>
210
+ {#if visible.length === 0 && empty}
211
+ <tr>
212
+ <td colspan={columns.length + 1}>{@render empty()}</td>
210
213
  </tr>
211
- {/each}
212
- {:else if visible.length === 0 && empty}
213
- <tr>
214
- <td colspan={columns.length + 1}>{@render empty()}</td>
215
- </tr>
216
- {:else}
217
- {#each visible as row (rowKey(row))}
218
- <tr class={cn(dataTrClass, "group", opens && "relative cursor-pointer")}>
219
- <td class={cn(dataTdClass, "py-3")}>
220
- {#if rowHref}
221
- <a href={rowHref(row)} class={stretch}>{@render lead(row)}</a>
222
- {:else if onRowClick}
223
- <button type="button" class={stretch} onclick={() => onRowClick(row)}>
224
- {@render lead(row)}
225
- </button>
226
- {:else}
227
- {@render lead(row)}
228
- {/if}
229
- </td>
230
- {#each columns as column (column.key)}
231
- <td
232
- class={cn(
233
- dataTdNumClass,
234
- columnClass(column),
235
- "align-middle whitespace-nowrap",
236
- column.strong && "font-medium",
237
- isMuted(column, row) && "text-muted-foreground",
238
- column.negative?.(row) && "text-destructive-ink",
239
- )}
240
- title={column.title?.(row)}
241
- >
242
- {#if column.cell}
243
- {@render column.cell(row)}
244
- {:else if column.rating}
245
- {@const rating = column.rating(row)}
246
- {#if rating}
247
- <RatingChip grade={rating.grade} hint={rating.hint} />
248
- {:else}
249
- <span class="text-muted-foreground">{emptyLabel}</span>
250
- {/if}
214
+ {:else}
215
+ {#each visible as row (rowKey(row))}
216
+ <tr class={cn(dataTrClass, "group", opens && "relative cursor-pointer")}>
217
+ <td class={cn(dataTdClass, "py-3")}>
218
+ {#if rowHref}
219
+ <a href={rowHref(row)} class={stretch}>{@render lead(row)}</a>
220
+ {:else if onRowClick}
221
+ <button type="button" class={stretch} onclick={() => onRowClick(row)}>
222
+ {@render lead(row)}
223
+ </button>
251
224
  {:else}
252
- {@const value = column.value?.(row) ?? null}
253
- {#if value === null}
254
- <span class="text-muted-foreground">{emptyLabel}</span>
255
- {:else if column.bar}
256
- <span class="flex items-center justify-end gap-3">
257
- <span class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
258
- <span
259
- class="block h-full origin-left rounded-full bg-primary/70"
260
- style:transform="scaleX({Math.min(1, Math.max(0, column.bar(row)))})"
261
- ></span>
262
- </span>
263
- <span>{value}</span>
264
- </span>
265
- {:else}
266
- {value}
267
- {/if}
225
+ {@render lead(row)}
268
226
  {/if}
269
227
  </td>
270
- {/each}
271
- </tr>
272
- {/each}
273
- {/if}
274
- </tbody>
228
+ {#each columns as column (column.key)}
229
+ <td
230
+ class={cn(
231
+ dataTdNumClass,
232
+ columnClass(column),
233
+ "align-middle whitespace-nowrap",
234
+ column.strong && "font-medium",
235
+ isMuted(column, row) && "text-muted-foreground",
236
+ column.negative?.(row) && "text-destructive-ink",
237
+ )}
238
+ >
239
+ <Hint text={column.title?.(row)} class="block">
240
+ {#if column.cell}
241
+ {@render column.cell(row)}
242
+ {:else if column.rating}
243
+ {@const rating = column.rating(row)}
244
+ {#if rating}
245
+ <RatingChip grade={rating.grade} hint={rating.hint} />
246
+ {:else}
247
+ <span class="text-muted-foreground">{emptyLabel}</span>
248
+ {/if}
249
+ {:else}
250
+ {@const value = column.value?.(row) ?? null}
251
+ {#if value === null}
252
+ <span class="text-muted-foreground">{emptyLabel}</span>
253
+ {:else if column.bar}
254
+ <span class="flex items-center justify-end gap-3">
255
+ <span class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
256
+ <span
257
+ class="block h-full origin-left rounded-full bg-primary/70"
258
+ style:transform="scaleX({Math.min(1, Math.max(0, column.bar(row)))})"
259
+ ></span>
260
+ </span>
261
+ <span>{value}</span>
262
+ </span>
263
+ {:else}
264
+ {value}
265
+ {/if}
266
+ {/if}
267
+ </Hint>
268
+ </td>
269
+ {/each}
270
+ </tr>
271
+ {/each}
272
+ {/if}
273
+ </tbody>
274
+ {/if}
275
275
  </table>
276
276
  {#if pageSize && !loading}
277
277
  <Pagination {page} {pageSize} total={sorted.length} onPageChange={(next) => (page = next)} />
@@ -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>
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { dualAxis, niceStep } from "./dual-axis.js";
3
+
4
+ describe("niceStep", () => {
5
+ it("rounds up to 1, 2, 2.5 or 5 times a power of ten", () => {
6
+ expect(niceStep(40)).toBe(50);
7
+ expect(niceStep(1500)).toBe(2000);
8
+ expect(niceStep(2100)).toBe(2500);
9
+ expect(niceStep(0.3)).toBeCloseTo(0.5);
10
+ expect(niceStep(0)).toBe(1);
11
+ });
12
+ });
13
+
14
+ describe("dualAxis", () => {
15
+ it("gives clicks and impressions the same number of grid lines", () => {
16
+ const { left, right } = dualAxis(160, 6000);
17
+ expect(left).toEqual({ max: 200, ticks: [0, 50, 100, 150, 200] });
18
+ expect(right).toEqual({ max: 8000, ticks: [0, 2000, 4000, 6000, 8000] });
19
+ });
20
+
21
+ it("keeps every point inside both domains", () => {
22
+ for (const [l, r] of [
23
+ [7, 9_999],
24
+ [123_456, 3],
25
+ [1, 1],
26
+ [0.42, 87],
27
+ ]) {
28
+ const { left, right } = dualAxis(l, r);
29
+ expect(left.max).toBeGreaterThanOrEqual(l);
30
+ expect(right.max).toBeGreaterThanOrEqual(r);
31
+ expect(left.ticks.length).toBe(right.ticks.length);
32
+ }
33
+ });
34
+
35
+ it("draws one interval for an empty series instead of dividing by zero", () => {
36
+ const { left, right } = dualAxis(0, 0);
37
+ expect(left.ticks).toEqual([0, 1]);
38
+ expect(right.ticks).toEqual([0, 1]);
39
+ });
40
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Two value axes that share one set of grid lines.
3
+ *
4
+ * A right axis with its own nice ticks lands its labels between the left axis's grid lines, so a
5
+ * reader can't trace either. Both axes here get the same number of intervals: the left picks a nice
6
+ * step for its data, and the right picks the smallest nice step that fits its data in that many.
7
+ */
8
+
9
+ export type AxisScale = { max: number; ticks: number[] };
10
+
11
+ const NICE = [1, 2, 2.5, 5, 10];
12
+
13
+ /** The smallest 1, 2, 2.5 or 5 times a power of ten that is at least `raw`. */
14
+ export function niceStep(raw: number): number {
15
+ if (!(raw > 0) || !Number.isFinite(raw)) return 1;
16
+ const power = 10 ** Math.floor(Math.log10(raw));
17
+ // WHY the epsilon: 0.3 / 0.1 is 2.9999999999999996, which would skip 3 x 0.1 for the next step.
18
+ const multiple = NICE.find((m) => m * power >= raw * (1 - 1e-9)) ?? 10;
19
+ return multiple * power;
20
+ }
21
+
22
+ function ticksOf(step: number, count: number): number[] {
23
+ // Rounded so 0.1 x 3 reads 0.3 on the axis, not 0.30000000000000004.
24
+ return Array.from({ length: count + 1 }, (_, i) => Number((i * step).toPrecision(12)));
25
+ }
26
+
27
+ /**
28
+ * Left and right domains from 0, with the same tick count so both axes sit on one grid.
29
+ * `target` is the number of intervals wanted; the left data may round it to one more or less.
30
+ */
31
+ export function dualAxis(
32
+ leftMax: number,
33
+ rightMax: number,
34
+ target = 4,
35
+ ): { left: AxisScale; right: AxisScale } {
36
+ const leftStep = niceStep(Math.max(0, leftMax) / target);
37
+ const count = Math.max(1, Math.ceil(Math.max(0, leftMax) / leftStep - 1e-9));
38
+ const rightStep = niceStep(Math.max(0, rightMax) / count);
39
+ return {
40
+ left: { max: leftStep * count, ticks: ticksOf(leftStep, count) },
41
+ right: { max: rightStep * count, ticks: ticksOf(rightStep, count) },
42
+ };
43
+ }
@@ -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);