@heyramzi/ui 2.12.0 → 2.14.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.
@@ -16,3 +16,4 @@ export { default as AnimatedValue } from "../../src/charts/animated-value.svelte
16
16
  export { default as EdgeFade } from "../../src/charts/edge-fade.svelte";
17
17
  export { resolveEdgeFade, EDGE_FADE_DEFAULT } from "./edge-fade.js";
18
18
  export { ChartContainer, ChartTooltip, ChartStyle, getPayloadConfigFromPayload, useChart, setChartContext, THEMES } from "./primitives/index.js";
19
+ export { smoothSeries, smoothMode, ticksForWidth } from "./smoothing.js";
@@ -0,0 +1,94 @@
1
+ /** The tooltip's name for a drawn point, beside its date. */
2
+ export const ROLLING_LABEL = "7-day average";
3
+ export const weekOf = (start) => `Week of ${start}`;
4
+ export const averageTo = (end) => `${ROLLING_LABEL} to ${end}`;
5
+ const DAY = 864e5;
6
+ const WINDOW = 7;
7
+ // =========================
8
+ // FUNCTIONS
9
+ // =========================
10
+ /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
11
+ export function smoothMode(rangeDays) {
12
+ if (rangeDays <= 14) return "raw";
13
+ if (rangeDays <= 120) return "rolling";
14
+ return "weekly";
15
+ }
16
+ function timeOf(value) {
17
+ return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
18
+ }
19
+ /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
20
+ function sameForm(sample, time) {
21
+ if (sample instanceof Date) return new Date(time);
22
+ const iso = new Date(time).toISOString();
23
+ return String(sample).length === 10 ? iso.slice(0, 10) : iso;
24
+ }
25
+ /**
26
+ * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
27
+ * Totals stay the caller's raw sums; this only moves the drawn points.
28
+ */
29
+ export function smoothSeries(data, xKey, yKeys, { rangeDays, kind = "count" }) {
30
+ const mode = smoothMode(rangeDays);
31
+ if (mode === "raw" || data.length < WINDOW) return {
32
+ points: data,
33
+ mode: "raw"
34
+ };
35
+ const times = data.map((d) => timeOf(d[xKey]));
36
+ if (mode === "rolling") {
37
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
38
+ if (kind === "level") return {
39
+ points: data,
40
+ mode: "raw"
41
+ };
42
+ let from = 0;
43
+ const sums = Object.fromEntries(yKeys.map((k) => [k, 0]));
44
+ const points = data.map((d, i) => {
45
+ for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
46
+ while (times[i] - times[from] >= WINDOW * DAY) {
47
+ for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
48
+ from += 1;
49
+ }
50
+ // Divided by the days in the window, so a missing day counts as zero, not skipped.
51
+ const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
52
+ const row = { ...d };
53
+ for (const k of yKeys) row[k] = sums[k] / days;
54
+ return row;
55
+ });
56
+ return {
57
+ points,
58
+ mode
59
+ };
60
+ }
61
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
62
+ // a 3-day sum would draw a dip that never happened.
63
+ const last = times[times.length - 1];
64
+ const buckets = new Map();
65
+ data.forEach((d, i) => {
66
+ const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
67
+ buckets.set(index, [...buckets.get(index) ?? [], d]);
68
+ });
69
+ const oldest = Math.max(...buckets.keys());
70
+ const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
71
+ if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
72
+ const points = [...buckets.entries()].toSorted(([a], [b]) => b - a).map(([index, rows]) => {
73
+ const row = { ...rows[rows.length - 1] };
74
+ row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
75
+ for (const k of yKeys) {
76
+ row[k] = kind === "level" ? Number(rows[rows.length - 1][k] ?? 0) : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
77
+ }
78
+ return row;
79
+ });
80
+ return {
81
+ points,
82
+ mode
83
+ };
84
+ }
85
+ /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
86
+ export function ticksForWidth(max, spacing = 72) {
87
+ return (scale) => {
88
+ const [start, end] = scale.range();
89
+ const count = Math.max(2, Math.min(max, Math.floor(Math.abs(end - start) / spacing)));
90
+ const ticks = scale.ticks?.(count) ?? [];
91
+ const step = Math.ceil(ticks.length / count);
92
+ return step > 1 ? ticks.filter((_, i) => i % step === 0) : ticks;
93
+ };
94
+ }
@@ -0,0 +1 @@
1
+ export { default as TagChip } from "../../../src/components/chip/tag-chip.svelte";
@@ -0,0 +1,6 @@
1
+ export { default as CountChip } from "../../../src/components/work-parts/count-chip.svelte";
2
+ export { default as GroupHead } from "../../../src/components/work-parts/group-head.svelte";
3
+ export { default as PropertyRow } from "../../../src/components/work-parts/property-row.svelte";
4
+ export { default as WorkCard } from "../../../src/components/work-parts/work-card.svelte";
5
+ export { default as WorkWell } from "../../../src/components/work-parts/work-well.svelte";
6
+ export { docLooks, ganttLooks, workLooks } from "./looks.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The shapes the work organisms (kanban, task list, gantt) put on wrappers they don't render
3
+ * themselves: a held card's hole, the focus ring around a consumer's card or row. The organism
4
+ * picks the role, this file owns the radius, so a token edit moves every view at once.
5
+ */
6
+ export const workLooks = {
7
+ /** A card-sized hole: a drop slot, the held card's own place, an empty column. */
8
+ hole: "rounded-xl bg-foreground/[0.03] outline-1 outline-dashed outline-border -outline-offset-1",
9
+ /** The wrapper around a consumer's card, so its focus ring follows the card's corners. */
10
+ card: "rounded-xl",
11
+ /** A list row or a rail entry: control radius. */
12
+ row: "rounded-lg",
13
+ /** A one-line row: 32px, 44px on touch (DESIGN.md, Patterns: list rows). The snippet stretches to fill it; a two-line row grows. */
14
+ rowHeight: "grid min-h-8 any-pointer-coarse:min-h-11",
15
+ /** A row-sized hole: an empty list group. */
16
+ rowHole: "rounded-lg outline-1 outline-dashed outline-border -outline-offset-1",
17
+ /** A link wrapped round a chip: the focus ring takes the chip's corners. */
18
+ chipLink: "rounded-sm outline-none focus-visible:shadow-(--ring-focus)",
19
+ /** A quiet grey box inside a card: a command, a quote. Chip radius, no edge. */
20
+ inset: "rounded-sm bg-muted/50",
21
+ /** A surface that holds a whole list: the card step, whose shadow draws its own hairline. */
22
+ panel: "rounded-xl bg-card text-card-foreground shadow-card"
23
+ };
24
+ /**
25
+ * The gantt's marks. A bar is a control-sized shape, a mark inside a row is chip-sized, and
26
+ * the two round ones are a progress track and today's date number, which DESIGN rule 15
27
+ * allows as a count.
28
+ */
29
+ export const ganttLooks = {
30
+ bar: "rounded-lg",
31
+ mark: "rounded-sm",
32
+ /** The readout that follows a dragged bar. */
33
+ tip: "rounded-sm bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background tabular-nums shadow-raised",
34
+ /** A title that didn't fit its bar, set beside it. */
35
+ outsideLabel: "rounded-sm bg-card/90 px-1 text-xs text-muted-foreground",
36
+ track: "rounded-full",
37
+ day: "rounded-full"
38
+ };
39
+ /**
40
+ * The doc pages' shapes. Their icon buttons and history button are controls, so they take the
41
+ * Button atom's radius; the edited chip is a chip; the page's icon tile and the demo's frame are
42
+ * card-sized.
43
+ */
44
+ export const docLooks = {
45
+ /** A 28px icon button or a full-width row button: control radius, as `Button`. */
46
+ control: "rounded-lg",
47
+ /** The "Edited" chip that opens the page's history. */
48
+ chip: "rounded-sm",
49
+ /** The icon tile above a page title. */
50
+ tile: "rounded-xl",
51
+ /** The frame a demo draws round a whole doc page. */
52
+ frame: "rounded-xl"
53
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -13,6 +13,14 @@
13
13
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
14
14
  import Reveal from "./reveal.svelte";
15
15
  import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
16
+ import {
17
+ ROLLING_LABEL,
18
+ averageTo,
19
+ smoothSeries,
20
+ weekOf,
21
+ ticksForWidth,
22
+ type Smoothing,
23
+ } from "./smoothing.js";
16
24
 
17
25
  // =========================
18
26
  // TYPES
@@ -47,6 +55,8 @@
47
55
  reference?: ChartReference;
48
56
  /** The headline while no point is hovered, like a period total. Defaults to the last point. */
49
57
  resting?: { value: number; label: string };
58
+ /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
59
+ smoothing?: Smoothing;
50
60
  class?: string;
51
61
  }
52
62
 
@@ -54,7 +64,7 @@
54
64
  // PROPS
55
65
  // =========================
56
66
  let {
57
- data,
67
+ data: input,
58
68
  xKey,
59
69
  yKey,
60
70
  series: seriesInput,
@@ -68,6 +78,7 @@
68
78
  xTicks = 6,
69
79
  reference,
70
80
  resting,
81
+ smoothing,
71
82
  class: className,
72
83
  }: Props = $props();
73
84
 
@@ -83,7 +94,13 @@
83
94
  queueMicrotask(() => {
84
95
  active = d;
85
96
  });
86
- return formatX(d[xKey]);
97
+ return pointLabel(d);
98
+ }
99
+
100
+ /** What a drawn point is: a day, the week it opens, or the end of a 7-day average. */
101
+ function pointLabel(d: TData): string {
102
+ const x = formatX(d[xKey]);
103
+ return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
87
104
  }
88
105
 
89
106
  // =========================
@@ -101,6 +118,20 @@
101
118
  ? [{ key: yKey, label: yKey, color }]
102
119
  : [],
103
120
  );
121
+ const smoothed = $derived(
122
+ smoothing
123
+ ? smoothSeries(
124
+ input,
125
+ xKey,
126
+ series.map((s) => s.key),
127
+ smoothing,
128
+ )
129
+ : { points: input, mode: "raw" as const },
130
+ );
131
+ const data = $derived(smoothed.points);
132
+ const mode = $derived(smoothed.mode);
133
+ // The series label in the readout, unless the point is an average of the week before it.
134
+ const averageLabel = $derived(mode === "rolling" ? ROLLING_LABEL : null);
104
135
  const dataMax = $derived.by(() => {
105
136
  if (stacked) {
106
137
  return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
@@ -126,7 +157,11 @@
126
157
  }
127
158
  const figureRows = $derived(
128
159
  display
129
- ? series.map((s) => ({ value: Number(display[s.key]), label: s.label, color: s.color }))
160
+ ? series.map((s) => ({
161
+ value: Number(display[s.key]),
162
+ label: averageLabel ?? s.label,
163
+ color: s.color,
164
+ }))
130
165
  : null,
131
166
  );
132
167
  </script>
@@ -148,7 +183,7 @@
148
183
  size="md"
149
184
  />
150
185
  {:else if figureRows && display}
151
- <Figure series={figureRows} eyebrow={formatX(display[xKey])} format={formatValue} size="md" />
186
+ <Figure series={figureRows} eyebrow={pointLabel(display)} format={formatValue} size="md" />
152
187
  {:else}
153
188
  <Figure value="—" eyebrow="Latest" size="md" />
154
189
  {/if}
@@ -169,7 +204,7 @@
169
204
  >
170
205
  <Svg>
171
206
  <Axis placement="left" format={(v) => valueTick(Number(v))} ticks={4} />
172
- <Axis placement="bottom" format={(v) => formatX(v)} ticks={xTicks} />
207
+ <Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
173
208
  <g class="hr-area-grid">
174
209
  <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
175
210
  </g>
@@ -210,7 +245,7 @@
210
245
  <Tooltip.List>
211
246
  {#each series as s (s.key)}
212
247
  <Tooltip.Item
213
- label={s.label}
248
+ label={averageLabel ?? s.label}
214
249
  value={formatValue(Number(d[s.key]))}
215
250
  color={s.color}
216
251
  />
@@ -29,3 +29,11 @@ export {
29
29
  setChartContext,
30
30
  THEMES,
31
31
  } from "./primitives/index.js";
32
+ export {
33
+ smoothSeries,
34
+ smoothMode,
35
+ ticksForWidth,
36
+ type Smoothing,
37
+ type SeriesKind,
38
+ type SmoothMode,
39
+ } from "./smoothing.js";
@@ -0,0 +1,115 @@
1
+ // =========================
2
+ // TYPES
3
+ // =========================
4
+ /** A count sums per bucket (visitors, clicks); a level is a reading at a moment (MRR), never summed. */
5
+ export type SeriesKind = "count" | "level";
6
+
7
+ export interface Smoothing {
8
+ /** The window the chart shows, in days. It picks how much the line is smoothed. */
9
+ rangeDays: number;
10
+ kind?: SeriesKind;
11
+ }
12
+
13
+ export type SmoothMode = "raw" | "rolling" | "weekly";
14
+
15
+ /** The tooltip's name for a drawn point, beside its date. */
16
+ export const ROLLING_LABEL = "7-day average";
17
+ export const weekOf = (start: string) => `Week of ${start}`;
18
+ export const averageTo = (end: string) => `${ROLLING_LABEL} to ${end}`;
19
+
20
+ const DAY = 86_400_000;
21
+ const WINDOW = 7;
22
+
23
+ // =========================
24
+ // FUNCTIONS
25
+ // =========================
26
+ /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
27
+ export function smoothMode(rangeDays: number): SmoothMode {
28
+ if (rangeDays <= 14) return "raw";
29
+ if (rangeDays <= 120) return "rolling";
30
+ return "weekly";
31
+ }
32
+
33
+ function timeOf(value: unknown): number {
34
+ return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
35
+ }
36
+
37
+ /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
38
+ function sameForm(sample: unknown, time: number): unknown {
39
+ if (sample instanceof Date) return new Date(time);
40
+ const iso = new Date(time).toISOString();
41
+ return String(sample).length === 10 ? iso.slice(0, 10) : iso;
42
+ }
43
+
44
+ /**
45
+ * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
46
+ * Totals stay the caller's raw sums; this only moves the drawn points.
47
+ */
48
+ export function smoothSeries<TData extends Record<string, unknown>>(
49
+ data: TData[],
50
+ xKey: keyof TData & string,
51
+ yKeys: (keyof TData & string)[],
52
+ { rangeDays, kind = "count" }: Smoothing,
53
+ ): { points: TData[]; mode: SmoothMode } {
54
+ const mode = smoothMode(rangeDays);
55
+ if (mode === "raw" || data.length < WINDOW) return { points: data, mode: "raw" };
56
+ const times = data.map((d) => timeOf(d[xKey]));
57
+
58
+ if (mode === "rolling") {
59
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
60
+ if (kind === "level") return { points: data, mode: "raw" };
61
+ let from = 0;
62
+ const sums = Object.fromEntries(yKeys.map((k) => [k, 0])) as Record<string, number>;
63
+ const points = data.map((d, i) => {
64
+ for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
65
+ while (times[i] - times[from] >= WINDOW * DAY) {
66
+ for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
67
+ from += 1;
68
+ }
69
+ // Divided by the days in the window, so a missing day counts as zero, not skipped.
70
+ const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
71
+ const row: Record<string, unknown> = { ...d };
72
+ for (const k of yKeys) row[k] = sums[k] / days;
73
+ return row as TData;
74
+ });
75
+ return { points, mode };
76
+ }
77
+
78
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
79
+ // a 3-day sum would draw a dip that never happened.
80
+ const last = times[times.length - 1];
81
+ const buckets = new Map<number, TData[]>();
82
+ data.forEach((d, i) => {
83
+ const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
84
+ buckets.set(index, [...(buckets.get(index) ?? []), d]);
85
+ });
86
+ const oldest = Math.max(...buckets.keys());
87
+ const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
88
+ if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
89
+
90
+ const points = [...buckets.entries()]
91
+ .toSorted(([a], [b]) => b - a)
92
+ .map(([index, rows]) => {
93
+ const row: Record<string, unknown> = { ...rows[rows.length - 1] };
94
+ row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
95
+ for (const k of yKeys) {
96
+ row[k] =
97
+ kind === "level"
98
+ ? Number(rows[rows.length - 1][k] ?? 0)
99
+ : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
100
+ }
101
+ return row as TData;
102
+ });
103
+ return { points, mode };
104
+ }
105
+
106
+ /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
107
+ export function ticksForWidth(max: number, spacing = 72) {
108
+ return (scale: { range: () => number[]; ticks?: (count: number) => unknown[] }) => {
109
+ const [start, end] = scale.range();
110
+ const count = Math.max(2, Math.min(max, Math.floor(Math.abs(end - start) / spacing)));
111
+ const ticks = scale.ticks?.(count) ?? [];
112
+ const step = Math.ceil(ticks.length / count);
113
+ return step > 1 ? ticks.filter((_, i) => i % step === 0) : ticks;
114
+ };
115
+ }
@@ -6,6 +6,13 @@
6
6
  import { Area, Chart, Highlight, LinearGradient, Points, Spline, Svg, Tooltip } from "layerchart";
7
7
  import { curveMonotoneX } from "d3-shape";
8
8
  import Reveal from "./reveal.svelte";
9
+ import {
10
+ ROLLING_LABEL,
11
+ averageTo,
12
+ smoothSeries,
13
+ weekOf,
14
+ type Smoothing,
15
+ } from "./smoothing.js";
9
16
 
10
17
  // =========================
11
18
  // TYPES
@@ -31,6 +38,10 @@
31
38
  */
32
39
  bare?: boolean;
33
40
  formatValue?: (v: number) => string;
41
+ /** The tooltip's date. */
42
+ formatX?: (v: unknown) => string;
43
+ /** Smooths the drawn line by the window shown, so a year doesn't draw as a barcode. */
44
+ smoothing?: Smoothing;
34
45
  class?: string;
35
46
  }
36
47
 
@@ -38,7 +49,7 @@
38
49
  // PROPS
39
50
  // =========================
40
51
  let {
41
- data,
52
+ data: input,
42
53
  xKey,
43
54
  yKey,
44
55
  color = "var(--chart-1)",
@@ -48,6 +59,8 @@
48
59
  showLast = false,
49
60
  bare = false,
50
61
  formatValue = (v) => v.toLocaleString(),
62
+ formatX = (v) => String(v),
63
+ smoothing,
51
64
  class: className,
52
65
  }: Props = $props();
53
66
 
@@ -63,13 +76,22 @@
63
76
  queueMicrotask(() => {
64
77
  active = d;
65
78
  });
66
- return String(d[xKey]);
79
+ const x = formatX(d[xKey]);
80
+ return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
67
81
  }
68
82
 
69
83
  // =========================
70
84
  // DERIVED
71
85
  // =========================
72
86
  const uid = `hr-spark-${Math.random().toString(36).slice(2, 8)}`;
87
+ const smoothed = $derived(
88
+ smoothing
89
+ ? smoothSeries(input, xKey, [yKey], smoothing)
90
+ : { points: input, mode: "raw" as const },
91
+ );
92
+ const data = $derived(smoothed.points);
93
+ const mode = $derived(smoothed.mode);
94
+ const valueLabel = $derived(mode === "rolling" ? ROLLING_LABEL : String(yKey));
73
95
  const curve = $derived(smooth ? curveMonotoneX : undefined);
74
96
  const last = $derived(data.length > 0 ? data[data.length - 1] : null);
75
97
  const display = $derived(active ?? last);
@@ -128,7 +150,7 @@
128
150
  {#snippet children({ data: d })}
129
151
  <Tooltip.Header value={trackActive(d as TData)} />
130
152
  <Tooltip.List>
131
- <Tooltip.Item label={String(yKey)} value={formatValue(Number(d[yKey]))} />
153
+ <Tooltip.Item label={valueLabel} value={formatValue(Number(d[yKey]))} />
132
154
  </Tooltip.List>
133
155
  {/snippet}
134
156
  </Tooltip.Root>
@@ -0,0 +1 @@
1
+ export { default as TagChip } from "./tag-chip.svelte";
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { X } from "phosphor-svelte";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> {
8
+ /** A leading mark: a tag icon, a colour dot, an avatar. */
9
+ icon?: Snippet;
10
+ /** Shows a remove button after the label. */
11
+ onremove?: () => void;
12
+ /** The remove button's accessible name. */
13
+ removeLabel?: string;
14
+ /**
15
+ * False keeps the remove button out of the tab order and stops it taking focus on press,
16
+ * for a chip inside a field whose input owns the keyboard (Backspace removes).
17
+ */
18
+ removeFocusable?: boolean;
19
+ children?: Snippet;
20
+ class?: string;
21
+ }
22
+
23
+ let {
24
+ icon,
25
+ onremove,
26
+ removeLabel = "Remove",
27
+ removeFocusable = true,
28
+ children,
29
+ class: className,
30
+ ...restProps
31
+ }: Props = $props();
32
+ </script>
33
+
34
+ <!-- A tag is a label the user wrote, not a state, so it's a bordered grey rectangle at the chip
35
+ radius and never a pill (DESIGN.md rules 2 and 15). -->
36
+ <span
37
+ data-slot="tag-chip"
38
+ class={cn(
39
+ "inline-flex h-5 max-w-full shrink-0 items-center gap-1 overflow-hidden rounded-sm border border-border bg-muted/60 px-1.5 text-xs text-foreground",
40
+ "[&>svg]:size-3 [&>svg]:shrink-0 [&>svg]:text-muted-foreground",
41
+ onremove && "pr-0.5",
42
+ className,
43
+ )}
44
+ {...restProps}
45
+ >
46
+ {@render icon?.()}
47
+ <span class="min-w-0 truncate">{@render children?.()}</span>
48
+ {#if onremove}
49
+ <!-- The glyph is 12px; the after: box takes the hit area to 24px without growing the chip. -->
50
+ <button
51
+ type="button"
52
+ class="relative grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground transition-colors duration-quick ease-smooth-out after:absolute after:-inset-1 hover:bg-foreground/8 hover:text-foreground focus-visible:shadow-[var(--ring-focus)] focus-visible:outline-none motion-reduce:transition-none"
53
+ aria-label={removeLabel}
54
+ tabindex={removeFocusable ? undefined : -1}
55
+ onmousedown={(e) => {
56
+ if (!removeFocusable) e.preventDefault();
57
+ }}
58
+ onclick={onremove}
59
+ >
60
+ <X class="size-3" weight="bold" />
61
+ </button>
62
+ {/if}
63
+ </span>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface Props {
5
+ count: number;
6
+ class?: string;
7
+ }
8
+
9
+ let { count, class: className }: Props = $props();
10
+ </script>
11
+
12
+ <!-- A count beside a heading: a quiet square, never a pill, so it reads as a number and not a status. -->
13
+ <span
14
+ class={cn(
15
+ "grid h-5 min-w-5 shrink-0 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums",
16
+ className,
17
+ )}
18
+ >
19
+ {count.toLocaleString()}
20
+ </span>
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { Plus } from "phosphor-svelte";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { Button } from "../../shadcn/button/index.js";
6
+ import CountChip from "./count-chip.svelte";
7
+
8
+ interface Props {
9
+ label: string;
10
+ /** The status colour of the dot. Leave out for no dot. */
11
+ hue?: string;
12
+ count: number;
13
+ /** Shows a plus at the end that adds an item to this group. */
14
+ onAdd?: () => void;
15
+ addLabel?: string;
16
+ /** Extra controls before the plus. */
17
+ children?: Snippet;
18
+ class?: string;
19
+ }
20
+
21
+ let { label, hue, count, onAdd, addLabel = "Add", children, class: className }: Props = $props();
22
+ </script>
23
+
24
+ <!-- A group's head, the same in a kanban column and a list group: dot, name, count, plus. -->
25
+ <div class={cn("flex h-8 min-w-0 flex-1 items-center gap-2", className)}>
26
+ {#if hue}
27
+ <span class="size-2 shrink-0 rounded-full" style:background={hue} aria-hidden="true"></span>
28
+ {/if}
29
+ <span class="truncate text-sm font-medium text-foreground">{label}</span>
30
+ <CountChip {count} />
31
+ {#if children || onAdd}
32
+ <span class="ms-auto flex shrink-0 items-center gap-0.5">
33
+ {@render children?.()}
34
+ {#if onAdd}
35
+ <Button
36
+ variant="ghost"
37
+ size="sm"
38
+ icon
39
+ aria-label="{addLabel}: {label}"
40
+ class="text-muted-foreground"
41
+ onclick={onAdd}
42
+ >
43
+ <Plus weight="bold" aria-hidden="true" />
44
+ </Button>
45
+ {/if}
46
+ </span>
47
+ {/if}
48
+ </div>
@@ -0,0 +1,6 @@
1
+ export { default as CountChip } from "./count-chip.svelte";
2
+ export { default as GroupHead } from "./group-head.svelte";
3
+ export { default as PropertyRow } from "./property-row.svelte";
4
+ export { default as WorkCard } from "./work-card.svelte";
5
+ export { default as WorkWell } from "./work-well.svelte";
6
+ export { docLooks, ganttLooks, workLooks } from "./looks.js";