@artooi/ag-ui-web-component 0.33.0 → 0.34.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.
@@ -11,6 +11,20 @@
11
11
  * Hand-rolled rather than a charting library, and the difference is not
12
12
  * marginal: the whole renderer costs single-digit kilobytes where a library
13
13
  * costs roughly half this bundle again, in a component distributed over a CDN.
14
+ *
15
+ * **The drawing is sized in CSS pixels, not scaled to them.** An SVG with a
16
+ * fixed viewBox and `width: 100%` is a picture the browser magnifies: widen the
17
+ * panel and every stroke, every label and the whole frame grow with it. So the
18
+ * geometry is computed for the width the block actually has, one user unit to
19
+ * one CSS pixel, and recomputed when that width changes -- a 10px label is
20
+ * 10px at every size the block is given.
21
+ *
22
+ * How wide the block is allowed to get is the stylesheet's question, not this
23
+ * module's, and the answer there is that a chart stops at its own width rather
24
+ * than stretching with the panel, the way a message does. The two halves are
25
+ * separate on purpose: this one keeps a chart from being magnified, that one
26
+ * keeps it from being stretched, and a host that raises the cap gets a bigger
27
+ * chart with the same 10px labels rather than a magnified one.
14
28
  */
15
29
 
16
30
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -32,11 +46,55 @@ export interface ChartSpec {
32
46
  readonly series: readonly ChartSeries[];
33
47
  }
34
48
 
35
- const WIDTH = 480;
36
- const HEIGHT = 220;
49
+ /** The frame a chart is drawn into, in CSS pixels. */
50
+ interface Geometry {
51
+ readonly width: number;
52
+ readonly height: number;
53
+ /** The plotting area inside the padding. */
54
+ readonly plotW: number;
55
+ readonly plotH: number;
56
+ }
57
+
37
58
  const PAD = { top: 20, right: 12, bottom: 30, left: 44 };
38
- const PLOT_W = WIDTH - PAD.left - PAD.right;
39
- const PLOT_H = HEIGHT - PAD.top - PAD.bottom;
59
+
60
+ /**
61
+ * The width drawn before the block has been measured -- while it is still
62
+ * detached, and in a caller that never puts it in a document at all. It is the
63
+ * width this renderer drew at unconditionally before it could measure, so a
64
+ * chart that is never measured looks exactly as it always did.
65
+ */
66
+ const DEFAULT_WIDTH = 480;
67
+
68
+ /**
69
+ * The narrowest frame worth computing. Below this the SVG scales down as it
70
+ * always did, which is the right answer at the bottom end: a 200px chart with
71
+ * 10px labels has no room for the labels either way, and shrinking them keeps
72
+ * the shape readable.
73
+ */
74
+ const MIN_WIDTH = 220;
75
+
76
+ /**
77
+ * How tall a chart is for its width, and the band that holds. The ratio is the
78
+ * old fixed 480x220 frame, so the default width draws precisely what it drew
79
+ * before; the band is what stops a wide panel from turning a chart into a
80
+ * banner or a narrow one into a strip.
81
+ */
82
+ const HEIGHT_RATIO = 220 / 480;
83
+ const MIN_HEIGHT = 160;
84
+ const MAX_HEIGHT = 320;
85
+
86
+ /**
87
+ * The step a measured width is rounded to before it is redrawn.
88
+ *
89
+ * The SVG keeps `width="100%"`, so it fills its block exactly whatever the
90
+ * viewBox says; rounding the viewBox to 8px therefore costs at most a 3%
91
+ * scale at the narrow end and nothing anyone can see, while cutting the
92
+ * redraws during a panel drag from one per pixel to one per eight.
93
+ */
94
+ const WIDTH_STEP = 8;
95
+
96
+ /** A rough advance width per character for the 10px axis font. */
97
+ const AXIS_CHAR_WIDTH = 5.6;
40
98
 
41
99
  // Read from the host's own palette rather than a fixed ramp: the component
42
100
  // themes through custom properties everywhere else, and a chart that ignored
@@ -57,6 +115,18 @@ export function seriesColor(index: number): string {
57
115
  return SERIES_COLORS[index % SERIES_COLORS.length] as string;
58
116
  }
59
117
 
118
+ /** The frame for a block of this width. */
119
+ function geometryFor(width: number): Geometry {
120
+ const w = Math.max(MIN_WIDTH, width);
121
+ const h = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(w * HEIGHT_RATIO)));
122
+ return {
123
+ width: w,
124
+ height: h,
125
+ plotW: w - PAD.left - PAD.right,
126
+ plotH: h - PAD.top - PAD.bottom,
127
+ };
128
+ }
129
+
60
130
  function el<K extends keyof SVGElementTagNameMap>(
61
131
  name: K,
62
132
  attrs: Record<string, string | number>,
@@ -108,23 +178,44 @@ function extent(spec: ChartSpec): { min: number; max: number } {
108
178
  return max === min ? { min, max: max + 1 } : { min, max };
109
179
  }
110
180
 
111
- function scaleY(value: number, min: number, max: number): number {
112
- return PAD.top + PLOT_H - ((value - min) / (max - min)) * PLOT_H;
181
+ function scaleY(value: number, min: number, max: number, geo: Geometry): number {
182
+ return PAD.top + geo.plotH - ((value - min) / (max - min)) * geo.plotH;
113
183
  }
114
184
 
115
- function bandCentre(index: number, count: number): number {
116
- const step = PLOT_W / count;
185
+ function bandCentre(index: number, count: number, geo: Geometry): number {
186
+ const step = geo.plotW / count;
117
187
  return PAD.left + step * index + step / 2;
118
188
  }
119
189
 
120
- function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
190
+ /**
191
+ * Draw every nth label, for the smallest n whose labels have room.
192
+ *
193
+ * Axis text is a fixed 10px now rather than something that shrank with the
194
+ * frame, so at the narrow end the labels are the first thing to collide -- and
195
+ * a smear of overlapping words says less than half as many words with space
196
+ * around them. The character estimate only has to be good enough to pick a
197
+ * step; measuring text properly would mean laying it out first.
198
+ */
199
+ function labelStride(labels: readonly string[], geo: Geometry): number {
200
+ const band = geo.plotW / labels.length;
201
+ const widest = Math.max(...labels.map((label) => label.length)) * AXIS_CHAR_WIDTH;
202
+ return Math.max(1, Math.ceil(widest / band));
203
+ }
204
+
205
+ function drawAxes(
206
+ svg: SVGSVGElement,
207
+ spec: ChartSpec,
208
+ geo: Geometry,
209
+ min: number,
210
+ max: number,
211
+ ): void {
121
212
  for (const value of [min, max]) {
122
- const y = scaleY(value, min, max);
213
+ const y = scaleY(value, min, max, geo);
123
214
  svg.appendChild(
124
215
  el("line", {
125
216
  x1: PAD.left,
126
217
  y1: y,
127
- x2: WIDTH - PAD.right,
218
+ x2: geo.width - PAD.right,
128
219
  y2: y,
129
220
  stroke: "currentColor",
130
221
  "stroke-opacity": value === min ? 0.35 : 0.12,
@@ -134,24 +225,34 @@ function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
134
225
  text(String(Math.round(value)), { x: PAD.left - 6, y: y + 4, "text-anchor": "end" }),
135
226
  );
136
227
  }
228
+ const stride = labelStride(spec.labels, geo);
137
229
  spec.labels.forEach((label, i) => {
230
+ if (i % stride !== 0) {
231
+ return;
232
+ }
138
233
  svg.appendChild(
139
234
  text(label, {
140
- x: bandCentre(i, spec.labels.length),
141
- y: HEIGHT - PAD.bottom + 16,
235
+ x: bandCentre(i, spec.labels.length, geo),
236
+ y: geo.height - PAD.bottom + 16,
142
237
  "text-anchor": "middle",
143
238
  }),
144
239
  );
145
240
  });
146
241
  }
147
242
 
148
- function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
149
- const step = PLOT_W / spec.labels.length;
243
+ function drawBars(
244
+ svg: SVGSVGElement,
245
+ spec: ChartSpec,
246
+ geo: Geometry,
247
+ min: number,
248
+ max: number,
249
+ ): void {
250
+ const step = geo.plotW / spec.labels.length;
150
251
  const width = (step * 0.7) / spec.series.length;
151
- const base = scaleY(min, min, max);
252
+ const base = scaleY(min, min, max, geo);
152
253
  spec.series.forEach((series, s) => {
153
254
  series.points.forEach((value, i) => {
154
- const y = scaleY(value, min, max);
255
+ const y = scaleY(value, min, max, geo);
155
256
  svg.appendChild(
156
257
  el("rect", {
157
258
  x: PAD.left + step * i + step * 0.15 + width * s,
@@ -166,8 +267,14 @@ function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
166
267
  });
167
268
  }
168
269
 
169
- function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
170
- const step = PLOT_W / spec.labels.length;
270
+ function drawStacked(
271
+ svg: SVGSVGElement,
272
+ spec: ChartSpec,
273
+ geo: Geometry,
274
+ min: number,
275
+ max: number,
276
+ ): void {
277
+ const step = geo.plotW / spec.labels.length;
171
278
  const width = step * 0.7;
172
279
  // Indexed by the same `i` it was built from, so every read is a hit; cast
173
280
  // rather than defaulting, which would add a branch nothing can reach.
@@ -182,13 +289,13 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
182
289
  const from = running[i] ?? 0;
183
290
  const to = from + value;
184
291
  running[i] = to;
185
- const y = scaleY(to, min, max);
292
+ const y = scaleY(to, min, max, geo);
186
293
  svg.appendChild(
187
294
  el("rect", {
188
295
  x: PAD.left + step * i + step * 0.15,
189
296
  y,
190
297
  width,
191
- height: Math.max(1, scaleY(from, min, max) - y),
298
+ height: Math.max(1, scaleY(from, min, max, geo) - y),
192
299
  fill: seriesColor(s),
193
300
  }),
194
301
  );
@@ -196,10 +303,18 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
196
303
  });
197
304
  }
198
305
 
199
- function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
306
+ function drawLines(
307
+ svg: SVGSVGElement,
308
+ spec: ChartSpec,
309
+ geo: Geometry,
310
+ min: number,
311
+ max: number,
312
+ ): void {
200
313
  spec.series.forEach((series, s) => {
201
314
  const points = series.points
202
- .map((value, i) => `${bandCentre(i, spec.labels.length)},${scaleY(value, min, max)}`)
315
+ .map(
316
+ (value, i) => `${bandCentre(i, spec.labels.length, geo)},${scaleY(value, min, max, geo)}`,
317
+ )
203
318
  .join(" ");
204
319
  svg.appendChild(
205
320
  el("polyline", {
@@ -213,13 +328,19 @@ function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number
213
328
  });
214
329
  }
215
330
 
216
- function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
331
+ function drawScatter(
332
+ svg: SVGSVGElement,
333
+ spec: ChartSpec,
334
+ geo: Geometry,
335
+ min: number,
336
+ max: number,
337
+ ): void {
217
338
  spec.series.forEach((series, s) => {
218
339
  series.points.forEach((value, i) => {
219
340
  svg.appendChild(
220
341
  el("circle", {
221
- cx: bandCentre(i, spec.labels.length),
222
- cy: scaleY(value, min, max),
342
+ cx: bandCentre(i, spec.labels.length, geo),
343
+ cy: scaleY(value, min, max, geo),
223
344
  r: 4,
224
345
  fill: seriesColor(s),
225
346
  "fill-opacity": 0.85,
@@ -231,15 +352,15 @@ function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
231
352
 
232
353
  /**
233
354
  * Pie draws the **first** series' points as shares of their own total, one
234
- * wedge per label the only kind whose slices are the labels rather than the
355
+ * wedge per label -- the only kind whose slices are the labels rather than the
235
356
  * series, so a second series has nowhere to go and is ignored rather than
236
357
  * silently summed into the first.
237
358
  */
238
- function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
359
+ function drawPie(svg: SVGSVGElement, points: readonly number[], geo: Geometry): void {
239
360
  const total = points.reduce((sum, value) => sum + value, 0);
240
- const cx = WIDTH / 2;
241
- const cy = PAD.top + PLOT_H / 2;
242
- const r = Math.min(PLOT_W, PLOT_H) / 2;
361
+ const cx = geo.width / 2;
362
+ const cy = PAD.top + geo.plotH / 2;
363
+ const r = Math.min(geo.plotW, geo.plotH) / 2;
243
364
  if (total === 0) {
244
365
  // Every share is zero, so there is no wedge to draw and a full circle would
245
366
  // claim one slice owns everything. An outline says "nothing here" honestly.
@@ -273,6 +394,42 @@ function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
273
394
  });
274
395
  }
275
396
 
397
+ /** The whole drawing for one spec at one width. */
398
+ function drawSvg(spec: ChartSpec, geo: Geometry): SVGSVGElement {
399
+ const svg = el("svg", {
400
+ viewBox: `0 0 ${geo.width} ${geo.height}`,
401
+ width: "100%",
402
+ role: "img",
403
+ });
404
+ svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
405
+
406
+ if (spec.kind === "pie") {
407
+ // `series[0]` is guaranteed by renderChart's early return; a pie's slices
408
+ // are its labels, so a second series has nowhere to go and is ignored
409
+ // rather than silently summed into the first. Negative shares are floored,
410
+ // since a wedge cannot sweep backwards.
411
+ const first = spec.series[0] as ChartSeries;
412
+ drawPie(
413
+ svg,
414
+ first.points.map((value) => Math.max(0, value)),
415
+ geo,
416
+ );
417
+ return svg;
418
+ }
419
+ const { min, max } = extent(spec);
420
+ drawAxes(svg, spec, geo, min, max);
421
+ if (spec.kind === "bar") {
422
+ drawBars(svg, spec, geo, min, max);
423
+ } else if (spec.kind === "stacked") {
424
+ drawStacked(svg, spec, geo, min, max);
425
+ } else if (spec.kind === "line") {
426
+ drawLines(svg, spec, geo, min, max);
427
+ } else {
428
+ drawScatter(svg, spec, geo, min, max);
429
+ }
430
+ return svg;
431
+ }
432
+
276
433
  function buildLegend(entries: readonly string[]): HTMLDivElement | null {
277
434
  if (entries.length < 2) {
278
435
  return null;
@@ -292,12 +449,34 @@ function buildLegend(entries: readonly string[]): HTMLDivElement | null {
292
449
  return row;
293
450
  }
294
451
 
452
+ /**
453
+ * Redraw `block`'s chart whenever the width it has to fill changes.
454
+ *
455
+ * Nothing disconnects this, and nothing needs to: the observer is referenced
456
+ * only by the closure that made it, and an active observer holds its target
457
+ * rather than the other way round -- so a chart removed from the transcript
458
+ * takes its observer with it.
459
+ *
460
+ * The block's own width is read rather than the entry's box, because that is
461
+ * the number the redraw has to match and the entry carries several.
462
+ */
463
+ function fitToWidth(block: HTMLElement, redraw: (width: number) => void): void {
464
+ const observer = new ResizeObserver(() => {
465
+ redraw(Math.round(block.clientWidth / WIDTH_STEP) * WIDTH_STEP);
466
+ });
467
+ observer.observe(block);
468
+ }
469
+
295
470
  /**
296
471
  * Render one spec as a self-contained block, or `null` when it says nothing.
297
472
  *
298
473
  * A spec with no labels or no series is not drawn: an empty frame reads as
299
474
  * "there is no data" when the truth is "the caller sent nothing", and the two
300
475
  * deserve different answers.
476
+ *
477
+ * The block returned is already drawn at {@link DEFAULT_WIDTH} and redraws
478
+ * itself once it is in a document and knows how wide it really is, so a caller
479
+ * appends it exactly as before and never has to say how big it should be.
301
480
  */
302
481
  export function renderChart(spec: ChartSpec): HTMLDivElement | null {
303
482
  if (spec.labels.length === 0 || spec.series.length === 0) {
@@ -315,32 +494,8 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
315
494
  block.appendChild(heading);
316
495
  }
317
496
 
318
- const svg = el("svg", { viewBox: `0 0 ${WIDTH} ${HEIGHT}`, width: "100%", role: "img" });
319
- svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
320
-
321
- if (spec.kind === "pie") {
322
- // `series[0]` is guaranteed by the early return above; a pie's slices are
323
- // its labels, so a second series has nowhere to go and is ignored rather
324
- // than silently summed into the first. Negative shares are floored, since a
325
- // wedge cannot sweep backwards.
326
- const first = spec.series[0] as ChartSeries;
327
- drawPie(
328
- svg,
329
- first.points.map((value) => Math.max(0, value)),
330
- );
331
- } else {
332
- const { min, max } = extent(spec);
333
- drawAxes(svg, spec, min, max);
334
- if (spec.kind === "bar") {
335
- drawBars(svg, spec, min, max);
336
- } else if (spec.kind === "stacked") {
337
- drawStacked(svg, spec, min, max);
338
- } else if (spec.kind === "line") {
339
- drawLines(svg, spec, min, max);
340
- } else {
341
- drawScatter(svg, spec, min, max);
342
- }
343
- }
497
+ let width = DEFAULT_WIDTH;
498
+ let svg = drawSvg(spec, geometryFor(width));
344
499
  block.appendChild(svg);
345
500
 
346
501
  // Pie's slices are its labels; every other kind's are its series.
@@ -350,5 +505,15 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
350
505
  if (legend !== null) {
351
506
  block.appendChild(legend);
352
507
  }
508
+
509
+ fitToWidth(block, (measured) => {
510
+ if (measured === width) {
511
+ return;
512
+ }
513
+ width = measured;
514
+ const next = drawSvg(spec, geometryFor(width));
515
+ svg.replaceWith(next);
516
+ svg = next;
517
+ });
353
518
  return block;
354
519
  }
@@ -0,0 +1,24 @@
1
+ import { EDGE_MARGIN } from "../constants.js";
2
+ import type { Extent } from "./launcher_placement.js";
3
+ import type { PanelRect } from "./resize_handle.js";
4
+
5
+ /**
6
+ * Hold a panel inside the viewport, keeping its size.
7
+ *
8
+ * The near edge is clamped and the far edge follows, so a panel too large for
9
+ * the viewport is held against the near margin and left to the max-width and
10
+ * max-height rules rather than centred by force. The lower bound wins a
11
+ * contradiction for the same reason: `Math.min` first would put an oversized
12
+ * panel off the left edge instead of against the margin it can still honour.
13
+ */
14
+ export function clampPanel(
15
+ host: PanelRect,
16
+ viewport: Extent,
17
+ margin: number = EDGE_MARGIN,
18
+ ): PanelRect {
19
+ const width = host.right - host.left;
20
+ const height = host.bottom - host.top;
21
+ const left = Math.max(margin, Math.min(host.left, viewport.width - margin - width));
22
+ const top = Math.max(margin, Math.min(host.top, viewport.height - margin - height));
23
+ return { left, top, right: left + width, bottom: top + height };
24
+ }
@@ -1,3 +1,7 @@
1
+ import { EDGE_MARGIN } from "../constants.js";
2
+ import { clampPanel } from "./clamp_panel.js";
3
+ import { placeWidget } from "./place_widget.js";
4
+
1
5
  /** A box in viewport coordinates. */
2
6
  export interface LauncherBox {
3
7
  readonly left: number;
@@ -33,13 +37,6 @@ export interface LauncherPlacement {
33
37
  readonly launcherInset: string;
34
38
  }
35
39
 
36
- /**
37
- * The gutter a panel keeps from the viewport edge, matching the default
38
- * `--ag-ui-inset` so an undragged widget resolves to exactly the placement it
39
- * already had. Changing this moves every clamped panel.
40
- */
41
- const EDGE_MARGIN = 24;
42
-
43
40
  /**
44
41
  * Decide where a panel should open from a launcher the user has dragged.
45
42
  *
@@ -88,56 +85,23 @@ export function launcherPlacement(
88
85
  y: roomRunningDown >= roomRunningUp ? "top" : "bottom",
89
86
  };
90
87
 
91
- // The box the panel wants, then the box it can actually have. Clamping the
92
- // near edge covers the far edge too: the panel is a fixed size here, and a
93
- // panel too large for the viewport is held against the near margin and left
94
- // to the max-width and max-height rules rather than centred by force.
88
+ // The box the panel wants, then the box it can actually have. The launcher
89
+ // is not moved by that clamp -- it is the fixed point of this gesture, so its
90
+ // own inset carries the difference and it can end up outside the host box.
95
91
  const wantedLeft =
96
92
  corner.x === "left" ? launcher.left : launcher.left + launcher.width - panel.width;
97
93
  const wantedTop =
98
94
  corner.y === "top" ? launcher.top : launcher.top + launcher.height - panel.height;
99
- const hostLeft = clamp(wantedLeft, margin, viewport.width - margin - panel.width);
100
- const hostTop = clamp(wantedTop, margin, viewport.height - margin - panel.height);
101
-
102
- const hostRight = hostLeft + panel.width;
103
- const hostBottom = hostTop + panel.height;
104
-
105
- return {
106
- corner,
107
- hostInset: inset({
108
- top: corner.y === "top" ? hostTop : null,
109
- right: corner.x === "right" ? viewport.width - hostRight : null,
110
- bottom: corner.y === "bottom" ? viewport.height - hostBottom : null,
111
- left: corner.x === "left" ? hostLeft : null,
112
- }),
113
- // Measured from the host box's pinned corner to the launcher's matching
114
- // corner, so the launcher lands exactly where it was dropped.
115
- launcherInset: inset({
116
- top: corner.y === "top" ? launcher.top - hostTop : null,
117
- right: corner.x === "right" ? hostRight - (launcher.left + launcher.width) : null,
118
- bottom: corner.y === "bottom" ? hostBottom - (launcher.top + launcher.height) : null,
119
- left: corner.x === "left" ? launcher.left - hostLeft : null,
120
- }),
121
- };
122
- }
123
-
124
- /**
125
- * Constrain a value, with the lower bound winning a contradiction. `Math.min`
126
- * first would put a panel wider than the viewport off the left edge instead of
127
- * against the near margin.
128
- */
129
- function clamp(value: number, low: number, high: number): number {
130
- return Math.max(low, Math.min(value, high));
131
- }
95
+ const host = clampPanel(
96
+ {
97
+ left: wantedLeft,
98
+ top: wantedTop,
99
+ right: wantedLeft + panel.width,
100
+ bottom: wantedTop + panel.height,
101
+ },
102
+ viewport,
103
+ margin,
104
+ );
132
105
 
133
- /** An `inset` shorthand; a null side is `auto`, so the opposite one pins it. */
134
- function inset(sides: {
135
- top: number | null;
136
- right: number | null;
137
- bottom: number | null;
138
- left: number | null;
139
- }): string {
140
- const side = (value: number | null): string =>
141
- value === null ? "auto" : `${Math.round(value)}px`;
142
- return `${side(sides.top)} ${side(sides.right)} ${side(sides.bottom)} ${side(sides.left)}`;
106
+ return { corner, ...placeWidget(host, launcher, corner, viewport) };
143
107
  }
@@ -0,0 +1,134 @@
1
+ import type { PanelRect } from "./resize_handle.js";
2
+
3
+ /** What the drag needs from its host to do its job. */
4
+ export interface PanelDragOptions {
5
+ /**
6
+ * Whether the panel can be moved right now, read per interaction because
7
+ * both halves of the answer are live: a docked or full-bleed placement has
8
+ * nowhere to move the panel to, and a collapsed one has no panel on screen.
9
+ */
10
+ readonly enabled: () => boolean;
11
+ /** The panel's current box, in viewport coordinates. */
12
+ readonly rect: () => PanelRect;
13
+ /**
14
+ * Put the panel at this box. Called per pointer move, with the box the press
15
+ * started on -- the whole gesture is one translation of that box, and a host
16
+ * with anything else to move alongside the panel needs the same distance
17
+ * rather than a distance measured from wherever the last move left things.
18
+ */
19
+ readonly apply: (box: PanelRect, from: PanelRect) => void;
20
+ /** Called once per completed move, for persistence. Never per pointer move. */
21
+ readonly commit: (box: PanelRect, from: PanelRect) => void;
22
+ }
23
+
24
+ /**
25
+ * How far the pointer must travel before this counts as a drag. The header is
26
+ * also where the title is selected and the controls are pressed, so a press
27
+ * that wanders by a pixel has to remain a press.
28
+ */
29
+ const DRAG_THRESHOLD = 4;
30
+
31
+ /**
32
+ * Elements inside the header that own their own press. A drag started on one
33
+ * of these would move the panel out from under the control the user was
34
+ * aiming at, and every one of them is the only way to reach what it does.
35
+ */
36
+ const CONTROLS = "button, a[href], input, select, textarea, [contenteditable]";
37
+
38
+ /**
39
+ * Let the user move the whole widget by dragging the panel's header.
40
+ *
41
+ * The launcher can already be dragged, and this is the same gesture on the
42
+ * half of the widget that is on screen when it is open -- a chat panel is a
43
+ * window, and a window moves by its title bar. The two stay one position
44
+ * rather than two: the host answers a moved panel by moving the launcher with
45
+ * it, so collapsing after a drag leaves the launcher where the panel was.
46
+ *
47
+ * **No keyboard path here, deliberately.** Every other drag in this component
48
+ * has arrow keys on the handle, because the handle is a control and the
49
+ * capability is reachable nowhere else. A header is not a control: making it
50
+ * focusable would put a tab stop with no role in front of every keyboard user,
51
+ * ahead of the controls they actually came for. The capability is not lost --
52
+ * arrow keys on the collapsed launcher move the widget, and the panel follows
53
+ * it -- so what is missing is a shortcut, not the ability.
54
+ */
55
+ export function enablePanelDrag(handle: HTMLElement, options: PanelDragOptions): void {
56
+ handle.addEventListener("pointerdown", (event: PointerEvent) => {
57
+ // Secondary buttons open menus and paste on the platforms that have them;
58
+ // none of that is a drag.
59
+ //
60
+ // Three conditions in one arc, which coverage counts as one branch however
61
+ // many of them are deleted. Each is held by a named test in
62
+ // panel_drag.test.ts -- "ignores a secondary button", "does nothing where
63
+ // the placement has nowhere to move the panel", and "steps aside for a
64
+ // control in the header" -- verified by mutating each one out.
65
+ if (event.button !== 0 || !options.enabled() || onControl(event, handle)) {
66
+ return;
67
+ }
68
+ const start = options.rect();
69
+ const originX = event.clientX;
70
+ const originY = event.clientY;
71
+ let dragging = false;
72
+
73
+ const boxAt = (x: number, y: number): PanelRect => {
74
+ // Measured from the box the press started on, never from the live one:
75
+ // reading it each move would chase the panel as it moves and the travel
76
+ // would compound.
77
+ const dx = x - originX;
78
+ const dy = y - originY;
79
+ return {
80
+ left: start.left + dx,
81
+ top: start.top + dy,
82
+ right: start.right + dx,
83
+ bottom: start.bottom + dy,
84
+ };
85
+ };
86
+
87
+ const onMove = (move: PointerEvent): void => {
88
+ if (
89
+ !dragging &&
90
+ Math.hypot(move.clientX - originX, move.clientY - originY) < DRAG_THRESHOLD
91
+ ) {
92
+ return;
93
+ }
94
+ dragging = true;
95
+ handle.setAttribute("data-dragging", "true");
96
+ options.apply(boxAt(move.clientX, move.clientY), start);
97
+ };
98
+
99
+ const onUp = (up: PointerEvent): void => {
100
+ window.removeEventListener("pointermove", onMove);
101
+ window.removeEventListener("pointerup", onUp);
102
+ if (!dragging) {
103
+ return;
104
+ }
105
+ handle.removeAttribute("data-dragging");
106
+ options.commit(boxAt(up.clientX, up.clientY), start);
107
+ };
108
+
109
+ // The press is the panel's from here: without this the browser starts
110
+ // selecting the title text and the drag leaves a highlight behind it.
111
+ event.preventDefault();
112
+ // Listeners on `window`, not the header: a fast drag outruns the pointer
113
+ // and would otherwise strand the panel mid-move with no pointerup.
114
+ window.addEventListener("pointermove", onMove);
115
+ window.addEventListener("pointerup", onUp);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Whether the press landed on a control inside the header rather than on the
121
+ * header itself.
122
+ *
123
+ * The composed path rather than `target`, because a control a host slots into
124
+ * the header lives in the light DOM: retargeting reports the host element for
125
+ * it, which matches nothing. Everything below the handle is examined and
126
+ * nothing above it, so a control the header happens to sit inside is not one
127
+ * of ours.
128
+ */
129
+ function onControl(event: PointerEvent, handle: HTMLElement): boolean {
130
+ const path = event.composedPath();
131
+ return path
132
+ .slice(0, path.indexOf(handle))
133
+ .some((node) => node instanceof Element && node.matches(CONTROLS));
134
+ }