@ponchia/ui 0.5.0 → 0.6.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 (117) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/MIGRATIONS.json +14 -0
  3. package/README.md +28 -5
  4. package/annotations/index.d.ts +398 -276
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +315 -45
  7. package/behaviors/carousel.js +17 -16
  8. package/behaviors/combobox.js +47 -16
  9. package/behaviors/command.js +18 -15
  10. package/behaviors/connectors.js +4 -5
  11. package/behaviors/crosshair.js +4 -5
  12. package/behaviors/dialog.js +3 -2
  13. package/behaviors/disclosure.js +3 -2
  14. package/behaviors/dismissible.js +3 -2
  15. package/behaviors/forms.js +41 -13
  16. package/behaviors/glyph.js +4 -5
  17. package/behaviors/internal.js +47 -0
  18. package/behaviors/legend.js +23 -2
  19. package/behaviors/menu.js +3 -2
  20. package/behaviors/popover.js +78 -7
  21. package/behaviors/spotlight.js +4 -5
  22. package/behaviors/table.js +39 -12
  23. package/behaviors/tabs.js +14 -14
  24. package/behaviors/theme.js +5 -3
  25. package/behaviors/toast.js +13 -1
  26. package/classes/classes.json +1857 -0
  27. package/classes/index.d.ts +28 -13
  28. package/classes/index.js +34 -18
  29. package/classes/vscode.css-custom-data.json +12 -0
  30. package/connectors/index.d.ts +189 -69
  31. package/connectors/index.d.ts.map +1 -0
  32. package/connectors/index.js +120 -24
  33. package/css/app.css +43 -13
  34. package/css/base.css +15 -10
  35. package/css/connectors.css +17 -0
  36. package/css/content.css +7 -1
  37. package/css/dataviz.css +5 -1
  38. package/css/disclosure.css +38 -6
  39. package/css/dots.css +57 -0
  40. package/css/feedback.css +60 -2
  41. package/css/forms.css +42 -1
  42. package/css/legend.css +11 -7
  43. package/css/marks.css +38 -8
  44. package/css/motion.css +24 -44
  45. package/css/navigation.css +7 -0
  46. package/css/overlay.css +31 -1
  47. package/css/primitives.css +91 -5
  48. package/css/report.css +40 -63
  49. package/css/site.css +16 -2
  50. package/css/sources.css +43 -1
  51. package/css/spotlight.css +1 -1
  52. package/css/tokens.css +36 -1
  53. package/css/workbench.css +1 -1
  54. package/dist/bronto.css +1 -1
  55. package/dist/css/analytical.css +1 -1
  56. package/dist/css/app.css +1 -1
  57. package/dist/css/base.css +1 -1
  58. package/dist/css/connectors.css +1 -1
  59. package/dist/css/content.css +1 -1
  60. package/dist/css/disclosure.css +1 -1
  61. package/dist/css/dots.css +1 -1
  62. package/dist/css/feedback.css +1 -1
  63. package/dist/css/forms.css +1 -1
  64. package/dist/css/legend.css +1 -1
  65. package/dist/css/marks.css +1 -1
  66. package/dist/css/motion.css +1 -1
  67. package/dist/css/navigation.css +1 -1
  68. package/dist/css/overlay.css +1 -1
  69. package/dist/css/primitives.css +1 -1
  70. package/dist/css/report.css +1 -1
  71. package/dist/css/site.css +1 -1
  72. package/dist/css/sources.css +1 -1
  73. package/dist/css/spotlight.css +1 -1
  74. package/dist/css/tokens.css +1 -1
  75. package/dist/css/workbench.css +1 -1
  76. package/docs/adr/0003-theme-model.md +1 -1
  77. package/docs/annotations.md +94 -14
  78. package/docs/architecture.md +50 -6
  79. package/docs/contrast.md +116 -92
  80. package/docs/d2.md +195 -0
  81. package/docs/legends.md +18 -2
  82. package/docs/marks.md +9 -2
  83. package/docs/mermaid.md +152 -0
  84. package/docs/reference.md +78 -22
  85. package/docs/reporting.md +395 -57
  86. package/docs/sources.md +27 -0
  87. package/docs/stability.md +9 -2
  88. package/docs/usage.md +101 -4
  89. package/docs/vega.md +225 -0
  90. package/docs/workbench.md +7 -1
  91. package/glyphs/glyphs.js +6 -4
  92. package/llms.txt +139 -14
  93. package/package.json +50 -12
  94. package/qwik/index.d.ts +42 -59
  95. package/qwik/index.d.ts.map +1 -0
  96. package/qwik/index.js +55 -3
  97. package/react/index.d.ts +39 -61
  98. package/react/index.d.ts.map +1 -0
  99. package/react/index.js +57 -3
  100. package/solid/index.d.ts +64 -61
  101. package/solid/index.d.ts.map +1 -0
  102. package/solid/index.js +60 -3
  103. package/tokens/d2.d.ts +38 -0
  104. package/tokens/d2.js +71 -0
  105. package/tokens/d2.json +43 -0
  106. package/tokens/index.d.ts +5 -5
  107. package/tokens/index.js +15 -1
  108. package/tokens/index.json +9 -0
  109. package/tokens/mermaid.d.ts +23 -0
  110. package/tokens/mermaid.js +181 -0
  111. package/tokens/mermaid.json +163 -0
  112. package/tokens/resolved.json +45 -1
  113. package/tokens/skins.js +3 -2
  114. package/tokens/tokens.dtcg.json +26 -0
  115. package/tokens/vega.d.ts +34 -0
  116. package/tokens/vega.js +155 -0
  117. package/tokens/vega.json +179 -0
@@ -1,47 +1,229 @@
1
1
  // Shared SVG geometry primitives live in the connectors kernel; annotations
2
2
  // (figure callouts) build on them so a line/curve/arrow/dot is drawn one way.
3
+
4
+ /**
5
+ * @ponchia/ui — SVG annotation geometry helpers.
6
+ *
7
+ * The public types below are JSDoc `@typedef`s; the shipped `index.d.ts` is
8
+ * generated from them (and these signatures) by `tsc --emitDeclarationOnly`.
9
+ *
10
+ * @typedef {{ x: number, y: number }} AnnotationPoint
11
+ * @typedef {{ dx: number, dy: number }} AnnotationOffset
12
+ * @typedef {'callout' | 'elbow' | 'curve'} AnnotationConnectorType
13
+ * @typedef {'start' | 'middle' | 'end'} AnnotationAlign
14
+ * @typedef {'top' | 'middle' | 'bottom'} AnnotationValign
15
+ * @typedef {'horizontal' | 'vertical'} AxisOrientation
16
+ * @typedef {'up' | 'down' | 'left' | 'right'} TimelineDirection
17
+ *
18
+ * @typedef {object} CircleSubject
19
+ * @property {'circle'} type
20
+ * @property {number} radius
21
+ * @property {number} [radiusPadding]
22
+ *
23
+ * @typedef {object} RectSubject
24
+ * @property {'rect'} type
25
+ * @property {number} width
26
+ * @property {number} height
27
+ * @property {number} [x]
28
+ * @property {number} [y]
29
+ * @property {number} [padding]
30
+ *
31
+ * @typedef {CircleSubject | RectSubject} ConnectorSubject
32
+ *
33
+ * @typedef {AnnotationOffset & { subject?: ConnectorSubject, mid?: number }} ConnectorOptions
34
+ *
35
+ * @typedef {object} CircleSubjectOptions
36
+ * @property {number} radius
37
+ *
38
+ * @typedef {object} RectSubjectOptions
39
+ * @property {number} width
40
+ * @property {number} height
41
+ * @property {number} [x]
42
+ * @property {number} [y]
43
+ * @property {number} [padding]
44
+ *
45
+ * @typedef {object} ThresholdOptions
46
+ * @property {number} [x1]
47
+ * @property {number} [y1]
48
+ * @property {number} x2
49
+ * @property {number} y2
50
+ *
51
+ * @typedef {object} AxisThresholdOptions
52
+ * @property {AxisOrientation} [orientation]
53
+ * @property {number} [value]
54
+ * @property {number} [start]
55
+ * @property {number} end
56
+ *
57
+ * @typedef {object} BracketSubjectOptions
58
+ * @property {number} x1
59
+ * @property {number} y1
60
+ * @property {number} x2
61
+ * @property {number} y2
62
+ * @property {number} [depth]
63
+ *
64
+ * @typedef {object} BandSubjectOptions
65
+ * @property {number} [x]
66
+ * @property {number} [y]
67
+ * @property {number} width
68
+ * @property {number} height
69
+ * @property {number} [padding]
70
+ *
71
+ * @typedef {object} SlopeSubjectOptions
72
+ * @property {number} x1
73
+ * @property {number} y1
74
+ * @property {number} x2
75
+ * @property {number} y2
76
+ *
77
+ * @typedef {object} ComparisonBraceOptions
78
+ * @property {number} x1
79
+ * @property {number} y1
80
+ * @property {number} x2
81
+ * @property {number} y2
82
+ * @property {number} [depth]
83
+ *
84
+ * @typedef {object} OutlierClusterOptions
85
+ * @property {AnnotationPoint[]} points
86
+ * @property {number} [radius]
87
+ *
88
+ * @typedef {object} TimelineEventOptions
89
+ * @property {number} [size]
90
+ * @property {TimelineDirection} [direction]
91
+ *
92
+ * @typedef {object} EvidenceMarkerOptions
93
+ * @property {number} [x]
94
+ * @property {number} [y]
95
+ * @property {number} [width]
96
+ * @property {number} [height]
97
+ * @property {number} [padding]
98
+ *
99
+ * @typedef {AnnotationPoint & { radius?: number }} ConnectorEndDotOptions
100
+ *
101
+ * @typedef {object} ConnectorEndArrowOptions
102
+ * @property {number} [x1]
103
+ * @property {number} [y1]
104
+ * @property {number} x2
105
+ * @property {number} y2
106
+ * @property {number} [size]
107
+ * @property {number} [spread] Half-angle of the arrowhead in radians (default
108
+ * 0.32 ≈ a crisp 37° included angle). Larger = blunter.
109
+ *
110
+ * @typedef {object} NoteTransformOptions
111
+ * @property {number} [dx]
112
+ * @property {number} [dy]
113
+ * @property {number} [x]
114
+ * @property {number} [y]
115
+ * @property {AnnotationAlign} [align]
116
+ * @property {AnnotationValign} [valign]
117
+ * @property {number} [width]
118
+ * @property {number} [height]
119
+ *
120
+ * @typedef {object} AnnotationBounds
121
+ * @property {number} [x]
122
+ * @property {number} [y]
123
+ * @property {number} width
124
+ * @property {number} height
125
+ *
126
+ * @typedef {object} NotePlacementOptions
127
+ * @property {number} [x]
128
+ * @property {number} [y]
129
+ * @property {number} width
130
+ * @property {number} height
131
+ * @property {AnnotationBounds} bounds
132
+ * @property {number} [padding]
133
+ * @property {number} [gap]
134
+ * @property {'right' | 'left' | 'top' | 'bottom'} [preferred]
135
+ * @property {number} [inset] Extra margin (user units) the note must keep from
136
+ * the bounds edge, on top of `padding`. Reserve the note's title stroke-halo
137
+ * (~3) or a leader stub so a placement that "fits" doesn't clip. Default 0.
138
+ *
139
+ * @typedef {object} NotePlacement
140
+ * @property {number} dx
141
+ * @property {number} dy
142
+ * @property {AnnotationAlign} align
143
+ * @property {AnnotationValign} valign
144
+ * @property {string} transform
145
+ *
146
+ * @typedef {(
147
+ * | CircleSubject
148
+ * | RectSubject
149
+ * | ({ type: 'threshold' } & ThresholdOptions)
150
+ * | ({ type: 'bracket' } & BracketSubjectOptions)
151
+ * | ({ type: 'band' } & BandSubjectOptions)
152
+ * | ({ type: 'slope' } & SlopeSubjectOptions)
153
+ * | ({ type: 'compare' } & ComparisonBraceOptions)
154
+ * | ({ type: 'cluster' } & OutlierClusterOptions)
155
+ * | ({ type: 'axis' } & AxisThresholdOptions)
156
+ * | ({ type: 'timeline' } & TimelineEventOptions)
157
+ * | ({ type: 'evidence' } & EvidenceMarkerOptions)
158
+ * )} AnnotationPartsSubject
159
+ *
160
+ * @typedef {object} AnnotationPartsOptions
161
+ * @property {AnnotationConnectorType} [type]
162
+ * @property {number} [x]
163
+ * @property {number} [y]
164
+ * @property {number} [dx]
165
+ * @property {number} [dy]
166
+ * @property {AnnotationPartsSubject} [subject]
167
+ *
168
+ * @typedef {object} AnnotationParts
169
+ * @property {string} transform
170
+ * @property {string} subject
171
+ * @property {string} connector
172
+ * @property {string} note
173
+ *
174
+ * @typedef {object} DeclutterLabelItem
175
+ * @property {number} pos Desired centre coordinate along the axis.
176
+ * @property {number} size The label's extent along the axis.
177
+ *
178
+ * @typedef {object} DeclutterLabelsOptions
179
+ * @property {number} [gap] Minimum gap kept between adjacent labels. Default 0.
180
+ * @property {number} [min] Lower bound of the axis. Default -Infinity.
181
+ * @property {number} [max] Upper bound of the axis. Default Infinity.
182
+ *
183
+ * @typedef {object} DirectLabelItem
184
+ * @property {AnnotationPoint} anchor The true data point the label refers to (figure coordinates).
185
+ * @property {number} size The label's extent along the layout axis.
186
+ * @property {string | number} [key] Optional identifier, echoed back on the matching output (input order).
187
+ *
188
+ * @typedef {object} DirectLabelsOptions
189
+ * @property {'x' | 'y'} [axis] Axis the labels declutter along. 'y' = a vertical column. Default 'y'.
190
+ * @property {number} [cross] Fixed coordinate on the other axis where the label column/row sits. Default 0.
191
+ * @property {number} [gap] Minimum gap kept between adjacent labels. Default 0.
192
+ * @property {number} [min] Lower bound of the layout axis. Default -Infinity.
193
+ * @property {number} [max] Upper bound of the layout axis. Default Infinity.
194
+ * @property {'straight' | 'elbow' | 'curve'} [shape] Leader-line shape. Default 'straight'.
195
+ *
196
+ * @typedef {object} DirectLabel
197
+ * @property {number} x Placed label point — the leader's label-side end.
198
+ * @property {number} y
199
+ * @property {AnnotationPoint} anchor The echoed input anchor.
200
+ * @property {string | number} [key] The echoed input key, if any.
201
+ * @property {string} d SVG path for the leader (anchor → label point); '' if they coincide.
202
+ */
203
+
3
204
  import {
4
205
  straightPath,
5
206
  curvePath,
6
207
  connectorPath,
208
+ elbowPath,
7
209
  arrowHead,
8
210
  dotMark,
9
211
  angleBetween,
212
+ // Shared scalar/geometry kernel — single source of truth (was copy-pasted,
213
+ // and the local clamp had silently diverged from the connectors one).
214
+ PRECISION,
215
+ finite,
216
+ dimension,
217
+ fmt,
218
+ point,
219
+ clamp,
10
220
  } from '../connectors/index.js';
11
221
 
12
- const PRECISION = 1000;
13
-
14
- function finite(name, value, fallback) {
15
- const v = value ?? fallback;
16
- if (!Number.isFinite(v)) throw new TypeError(`${name} must be a finite number`);
17
- return v;
18
- }
19
-
20
- function dimension(name, value, fallback) {
21
- const v = finite(name, value, fallback);
22
- if (v < 0) throw new RangeError(`${name} must be greater than or equal to 0`);
23
- return v;
24
- }
25
-
26
- function fmt(value) {
27
- const rounded = Math.round((Object.is(value, -0) ? 0 : value) * PRECISION) / PRECISION;
28
- return String(Object.is(rounded, -0) ? 0 : rounded);
29
- }
30
-
31
222
  function roundedNumber(value) {
32
223
  const rounded = Math.round((Object.is(value, -0) ? 0 : value) * PRECISION) / PRECISION;
33
224
  return Object.is(rounded, -0) ? 0 : rounded;
34
225
  }
35
226
 
36
- function point(x, y) {
37
- return `${fmt(x)},${fmt(y)}`;
38
- }
39
-
40
- function clamp(value, min, max) {
41
- if (max < min) return min;
42
- return Math.min(max, Math.max(min, value));
43
- }
44
-
45
227
  function circlePathAt(x, y, radius) {
46
228
  const r = dimension('radius', radius);
47
229
  if (r === 0) return '';
@@ -106,10 +288,18 @@ function linePath(start, end) {
106
288
  return `M${point(start.x, start.y)}L${point(end.x, end.y)}`;
107
289
  }
108
290
 
291
+ /**
292
+ * @param {Partial<AnnotationPoint>} [point]
293
+ * @returns {string}
294
+ */
109
295
  export function annotationTransform({ x = 0, y = 0 } = {}) {
110
296
  return `translate(${fmt(finite('x', x))}, ${fmt(finite('y', y))})`;
111
297
  }
112
298
 
299
+ /**
300
+ * @param {NoteTransformOptions} [options]
301
+ * @returns {string}
302
+ */
113
303
  export function noteTransform({
114
304
  dx,
115
305
  dy,
@@ -172,6 +362,10 @@ function noteRect(x, y, width, height, placement) {
172
362
  };
173
363
  }
174
364
 
365
+ /**
366
+ * @param {NotePlacementOptions} options
367
+ * @returns {NotePlacement}
368
+ */
175
369
  export function notePlacement({
176
370
  x = 0,
177
371
  y = 0,
@@ -181,6 +375,7 @@ export function notePlacement({
181
375
  padding = 8,
182
376
  gap = 32,
183
377
  preferred = 'right',
378
+ inset = 0,
184
379
  } = {}) {
185
380
  const anchorX = finite('x', x);
186
381
  const anchorY = finite('y', y);
@@ -188,14 +383,17 @@ export function notePlacement({
188
383
  const h = dimension('height', height);
189
384
  const p = dimension('padding', padding);
190
385
  const g = dimension('gap', gap);
386
+ const ins = dimension('inset', inset, 0);
191
387
  const bx = finite('bounds.x', bounds?.x, 0);
192
388
  const by = finite('bounds.y', bounds?.y, 0);
193
389
  const bw = dimension('bounds.width', bounds?.width);
194
390
  const bh = dimension('bounds.height', bounds?.height);
195
- const minX = bx + p;
196
- const minY = by + p;
197
- const maxX = bx + bw - p;
198
- const maxY = by + bh - p;
391
+ // `inset` reserves an extra margin (e.g. the title stroke-halo) inside the
392
+ // padded bounds, so a placement that "just fits" doesn't clip the halo/leader.
393
+ const minX = bx + p + ins;
394
+ const minY = by + p + ins;
395
+ const maxX = bx + bw - p - ins;
396
+ const maxY = by + bh - p - ins;
199
397
 
200
398
  for (const side of placementOrder(preferred)) {
201
399
  const placement = candidatePlacement(side, g);
@@ -226,10 +424,18 @@ export function notePlacement({
226
424
  };
227
425
  }
228
426
 
427
+ /**
428
+ * @param {CircleSubjectOptions} options
429
+ * @returns {string}
430
+ */
229
431
  export function circleSubjectPath({ radius } = {}) {
230
432
  return circlePathAt(0, 0, radius);
231
433
  }
232
434
 
435
+ /**
436
+ * @param {RectSubjectOptions} options
437
+ * @returns {string}
438
+ */
233
439
  export function rectSubjectPath({ width, height, x, y, padding = 0 } = {}) {
234
440
  const w = dimension('width', width);
235
441
  const h = dimension('height', height);
@@ -242,12 +448,20 @@ export function rectSubjectPath({ width, height, x, y, padding = 0 } = {}) {
242
448
  return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
243
449
  }
244
450
 
451
+ /**
452
+ * @param {ThresholdOptions} options
453
+ * @returns {string}
454
+ */
245
455
  export function thresholdPath({ x1 = 0, y1 = 0, x2, y2 } = {}) {
246
456
  const start = { x: finite('x1', x1), y: finite('y1', y1) };
247
457
  const end = { x: finite('x2', x2), y: finite('y2', y2) };
248
458
  return linePath(start, end);
249
459
  }
250
460
 
461
+ /**
462
+ * @param {AxisThresholdOptions} options
463
+ * @returns {string}
464
+ */
251
465
  export function axisThresholdPath({ orientation = 'horizontal', value = 0, start = 0, end } = {}) {
252
466
  const v = finite('value', value);
253
467
  const s = finite('start', start);
@@ -257,6 +471,10 @@ export function axisThresholdPath({ orientation = 'horizontal', value = 0, start
257
471
  throw new TypeError('orientation must be "horizontal" or "vertical"');
258
472
  }
259
473
 
474
+ /**
475
+ * @param {BracketSubjectOptions} options
476
+ * @returns {string}
477
+ */
260
478
  export function bracketSubjectPath({ x1, y1, x2, y2, depth = 12 } = {}) {
261
479
  const start = { x: finite('x1', x1), y: finite('y1', y1) };
262
480
  const end = { x: finite('x2', x2), y: finite('y2', y2) };
@@ -268,14 +486,26 @@ export function bracketSubjectPath({ x1, y1, x2, y2, depth = 12 } = {}) {
268
486
  return `M${point(start.x, start.y)}H${fmt(start.x + d)}V${fmt(end.y)}H${fmt(end.x)}`;
269
487
  }
270
488
 
489
+ /**
490
+ * @param {BandSubjectOptions} options
491
+ * @returns {string}
492
+ */
271
493
  export function bandSubjectPath({ x = 0, y = 0, width, height, padding = 0 } = {}) {
272
494
  return rectSubjectPath({ x, y, width, height, padding });
273
495
  }
274
496
 
497
+ /**
498
+ * @param {SlopeSubjectOptions} options
499
+ * @returns {string}
500
+ */
275
501
  export function slopeSubjectPath({ x1, y1, x2, y2 } = {}) {
276
502
  return thresholdPath({ x1, y1, x2, y2 });
277
503
  }
278
504
 
505
+ /**
506
+ * @param {ComparisonBraceOptions} options
507
+ * @returns {string}
508
+ */
279
509
  export function comparisonBracePath({ x1, y1, x2, y2, depth = 14 } = {}) {
280
510
  const start = { x: finite('x1', x1), y: finite('y1', y1) };
281
511
  const end = { x: finite('x2', x2), y: finite('y2', y2) };
@@ -313,6 +543,10 @@ export function comparisonBracePath({ x1, y1, x2, y2, depth = 14 } = {}) {
313
543
  )} ${point(x, end.y)}`;
314
544
  }
315
545
 
546
+ /**
547
+ * @param {OutlierClusterOptions} options
548
+ * @returns {string}
549
+ */
316
550
  export function outlierClusterPath({ points, radius = 6 } = {}) {
317
551
  if (!Array.isArray(points)) throw new TypeError('points must be an array');
318
552
  return points
@@ -323,6 +557,10 @@ export function outlierClusterPath({ points, radius = 6 } = {}) {
323
557
  .join('');
324
558
  }
325
559
 
560
+ /**
561
+ * @param {TimelineEventOptions} [options]
562
+ * @returns {string}
563
+ */
326
564
  export function timelineEventPath({ size = 10, direction = 'down' } = {}) {
327
565
  const s = dimension('size', size);
328
566
  if (s === 0) return '';
@@ -333,6 +571,10 @@ export function timelineEventPath({ size = 10, direction = 'down' } = {}) {
333
571
  throw new TypeError('direction must be "up", "down", "left" or "right"');
334
572
  }
335
573
 
574
+ /**
575
+ * @param {EvidenceMarkerOptions} [options]
576
+ * @returns {string}
577
+ */
336
578
  export function evidenceMarkerPath({ x = 0, y = 0, width = 36, height = 36, padding = 0 } = {}) {
337
579
  const w = dimension('width', width);
338
580
  const h = dimension('height', height);
@@ -347,18 +589,30 @@ export function evidenceMarkerPath({ x = 0, y = 0, width = 36, height = 36, padd
347
589
  return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
348
590
  }
349
591
 
592
+ /**
593
+ * @param {ConnectorEndDotOptions} options
594
+ * @returns {string}
595
+ */
350
596
  export function connectorEndDot({ x, y, radius = 3 } = {}) {
351
597
  return dotMark({ x: finite('x', x), y: finite('y', y) }, radius);
352
598
  }
353
599
 
354
- export function connectorEndArrow({ x1 = 0, y1 = 0, x2, y2, size = 7 } = {}) {
600
+ /**
601
+ * @param {ConnectorEndArrowOptions} options
602
+ * @returns {string}
603
+ */
604
+ export function connectorEndArrow({ x1 = 0, y1 = 0, x2, y2, size = 8, spread = 0.32 } = {}) {
355
605
  const start = { x: finite('x1', x1), y: finite('y1', y1) };
356
606
  const end = { x: finite('x2', x2), y: finite('y2', y2) };
357
607
  const s = dimension('size', size);
358
608
  if (s === 0 || (end.x === start.x && end.y === start.y)) return '';
359
- return arrowHead(end, angleBetween(start, end), s);
609
+ return arrowHead(end, angleBetween(start, end), s, spread);
360
610
  }
361
611
 
612
+ /**
613
+ * @param {ConnectorOptions} opts
614
+ * @returns {string}
615
+ */
362
616
  export function connectorLine(opts = {}) {
363
617
  const { dx, dy } = validateOffset(opts);
364
618
  if (dx === 0 && dy === 0) return '';
@@ -370,25 +624,29 @@ export function connectorLine(opts = {}) {
370
624
  return straightPath(start, end);
371
625
  }
372
626
 
627
+ /**
628
+ * @param {ConnectorOptions} opts
629
+ * @returns {string}
630
+ */
373
631
  export function connectorElbow(opts = {}) {
374
632
  const { dx, dy } = validateOffset(opts);
375
633
  if (dx === 0 && dy === 0) return '';
376
634
  const start = connectorStart(dx, dy, opts.subject);
377
635
  if (!start) return '';
378
636
  const end = { x: dx, y: dy };
379
- const vx = end.x - start.x;
380
- const vy = end.y - start.y;
381
- if (vx === 0 || vy === 0) return linePath(start, end);
382
-
383
- const elbow =
384
- Math.abs(vx) >= Math.abs(vy)
385
- ? { x: start.x + Math.sign(vx) * Math.abs(vy), y: end.y }
386
- : { x: end.x, y: start.y + Math.sign(vy) * Math.abs(vx) };
387
-
388
- if (samePoint(start, elbow) || samePoint(elbow, end)) return linePath(start, end);
389
- return `M${point(start.x, start.y)}L${point(elbow.x, elbow.y)}L${point(end.x, end.y)}`;
637
+ if (samePoint(start, end)) return '';
638
+ // A true right-angle dogleg (H/V/H), turning on the dominant axis at `mid`
639
+ // (0..1, default 0.5). Delegated to the connectors geometry kernel so an
640
+ // annotation leader and a node connector draw the same elbow. (The former
641
+ // inline form turned by min(|dx|,|dy|), i.e. a 45° chamfer that read as a
642
+ // diagonal stub, not an elbow — which the `stroke-linejoin` bevel assumes.)
643
+ return elbowPath(start, end, { mid: opts.mid });
390
644
  }
391
645
 
646
+ /**
647
+ * @param {ConnectorOptions} opts
648
+ * @returns {string}
649
+ */
392
650
  export function connectorCurve(opts = {}) {
393
651
  const { dx, dy } = validateOffset(opts);
394
652
  if (dx === 0 && dy === 0) return '';
@@ -400,6 +658,10 @@ export function connectorCurve(opts = {}) {
400
658
  return curvePath(start, end, { curvature: 0.35 });
401
659
  }
402
660
 
661
+ /**
662
+ * @param {AnnotationPartsOptions} [opts]
663
+ * @returns {AnnotationParts}
664
+ */
403
665
  export function annotationParts(opts = {}) {
404
666
  const type = opts.type ?? 'callout';
405
667
  const transform = annotationTransform({ x: opts.x ?? 0, y: opts.y ?? 0 });
@@ -442,6 +704,10 @@ export function annotationParts(opts = {}) {
442
704
  * `items`: `[{ pos, size }]` — `pos` is the desired centre coordinate along the
443
705
  * axis, `size` the label's extent along it. Returns the adjusted centre per
444
706
  * input item, in the original order.
707
+ *
708
+ * @param {DeclutterLabelItem[]} items
709
+ * @param {DeclutterLabelsOptions} [opts]
710
+ * @returns {number[]}
445
711
  */
446
712
  export function declutterLabels(items, opts = {}) {
447
713
  if (!Array.isArray(items)) throw new TypeError('items must be an array');
@@ -488,6 +754,10 @@ export function declutterLabels(items, opts = {}) {
488
754
  * in input order, the placed label point `{x, y}`, the echoed `anchor` and
489
755
  * `key`, and the leader path `d` (anchor → label; `''` if they coincide) ready
490
756
  * for a `<path class="ui-annotation__connector">`.
757
+ *
758
+ * @param {DirectLabelItem[]} items
759
+ * @param {DirectLabelsOptions} [opts]
760
+ * @returns {DirectLabel[]}
491
761
  */
492
762
  export function directLabels(items, opts = {}) {
493
763
  if (!Array.isArray(items)) throw new TypeError('items must be an array');
@@ -1,4 +1,11 @@
1
- import { hasDom, noop, bindOnce } from './internal.js';
1
+ import {
2
+ hasDom,
3
+ resolveHost,
4
+ noop,
5
+ bindOnce,
6
+ scrollIntoViewSafe,
7
+ collectHosts,
8
+ } from './internal.js';
2
9
 
3
10
  /**
4
11
  * Image carousel / gallery, built on CSS scroll-snap so touch + trackpad
@@ -25,10 +32,9 @@ import { hasDom, noop, bindOnce } from './internal.js';
25
32
  */
26
33
  export function initCarousel({ root } = {}) {
27
34
  if (!hasDom()) return noop;
28
- const host = root || document;
29
- const boxes = [];
30
- if (host !== document && host.matches?.('[data-bronto-carousel]')) boxes.push(host);
31
- boxes.push(...(host.querySelectorAll?.('[data-bronto-carousel]') ?? []));
35
+ const host = resolveHost(root);
36
+ if (!host) return noop;
37
+ const boxes = collectHosts(host, '[data-bronto-carousel]');
32
38
  const cleanups = [];
33
39
 
34
40
  for (const box of boxes) {
@@ -103,16 +109,7 @@ export function initCarousel({ root } = {}) {
103
109
  const emit = () =>
104
110
  box.dispatchEvent(new CustomEvent('bronto:change', { detail: { index }, bubbles: true }));
105
111
 
106
- // jsdom (and any layout-less env) has no scrollIntoView; it's a pure
107
- // affordance, so never let it break index/aria sync — same guard as
108
- // initCombobox.
109
- const reveal = (el) => {
110
- try {
111
- el?.scrollIntoView({ block: 'nearest', inline: 'center' });
112
- } catch {
113
- /* no layout — ignore */
114
- }
115
- };
112
+ const reveal = (el) => scrollIntoViewSafe(el, { block: 'nearest', inline: 'center' });
116
113
 
117
114
  const goTo = (i, { emitChange = true } = {}) => {
118
115
  const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
@@ -176,13 +173,17 @@ export function initCarousel({ root } = {}) {
176
173
  },
177
174
  { root: viewport, threshold: 0.6 },
178
175
  );
179
- slides.forEach((s) => io.observe(s));
180
176
  }
181
177
 
182
178
  render();
183
179
  const bound = bindOnce(box, 'carousel', () => {
184
180
  viewport.addEventListener('keydown', onKey);
185
181
  box.addEventListener('click', onClick);
182
+ // Observe inside the add callback so observe/disconnect pair with the
183
+ // binding lifecycle: a re-init tears down the prior binding (which
184
+ // disconnects the old observer) before this starts, so two observers
185
+ // never watch the same slides — even for one tick.
186
+ slides.forEach((s) => io?.observe(s));
186
187
  return () => {
187
188
  viewport.removeEventListener('keydown', onKey);
188
189
  box.removeEventListener('click', onClick);
@@ -1,4 +1,13 @@
1
- import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
1
+ import {
2
+ hasDom,
3
+ resolveHost,
4
+ noop,
5
+ bindOnce,
6
+ nextFieldUid,
7
+ scrollIntoViewSafe,
8
+ wrapIndex,
9
+ collectHosts,
10
+ } from './internal.js';
2
11
 
3
12
  /**
4
13
  * Editable combobox with a filtered listbox popup, implementing the
@@ -6,6 +15,11 @@ import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
6
15
  * and consumers most often build badly). Dependency-free, no
7
16
  * positioning library — the list is CSS-anchored under the input.
8
17
  *
18
+ * The input MUST have an accessible name — a `<label>`, `aria-label`, or
19
+ * `aria-labelledby` (a placeholder does not count). A nameless `role="combobox"`
20
+ * is a silent screen-reader failure, so the behavior warns at dev time when it
21
+ * finds one, and mirrors the input's name onto the listbox.
22
+ *
9
23
  * Markup: `[data-bronto-combobox]` wrapping an `<input role="combobox">`
10
24
  * (`.ui-combobox__input`) and a `<ul role="listbox">`
11
25
  * (`.ui-combobox__list`) of `<li role="option">` (`.ui-combobox__option`,
@@ -16,13 +30,16 @@ import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
16
30
  * pointer select, and outside-click close; it emits a `bronto:change`
17
31
  * CustomEvent ({ detail: { value } }) on selection. SSR-safe,
18
32
  * idempotent per instance; returns a cleanup function.
33
+ *
34
+ * Options are read from the DOM at init; if you replace the listbox contents
35
+ * (e.g. async/remote results) without re-initialising, filtering and keyboard
36
+ * nav act on the stale nodes — re-run initCombobox after mutating the options.
19
37
  */
20
38
  export function initCombobox({ root } = {}) {
21
39
  if (!hasDom()) return noop;
22
- const host = root || document;
23
- const boxes = [];
24
- if (host !== document && host.matches?.('[data-bronto-combobox]')) boxes.push(host);
25
- boxes.push(...(host.querySelectorAll?.('[data-bronto-combobox]') ?? []));
40
+ const host = resolveHost(root);
41
+ if (!host) return noop;
42
+ const boxes = collectHosts(host, '[data-bronto-combobox]');
26
43
  const cleanups = [];
27
44
 
28
45
  for (const box of boxes) {
@@ -38,6 +55,29 @@ export function initCombobox({ root } = {}) {
38
55
  o.setAttribute('role', 'option');
39
56
  });
40
57
  list.setAttribute('role', 'listbox');
58
+ // Give the listbox its own accessible name (a bare role=listbox is unnamed
59
+ // to a screen reader) by mirroring the input's name. (a11y review C30.)
60
+ if (!list.hasAttribute('aria-label') && !list.hasAttribute('aria-labelledby')) {
61
+ const name =
62
+ input.getAttribute('aria-label') ||
63
+ input.labels?.[0]?.textContent?.trim() ||
64
+ input.getAttribute('placeholder');
65
+ if (name) list.setAttribute('aria-label', name);
66
+ }
67
+ // A `role="combobox"` with no accessible name is a silent AT failure. A
68
+ // placeholder is not a robust name (it can vanish and is ignored by some
69
+ // AT), so warn unless there is a real label/aria-label/aria-labelledby/title
70
+ // (C7). We can't invent a good name, hence a dev-time warning, not a guess.
71
+ const inputNamed =
72
+ input.hasAttribute('aria-label') ||
73
+ input.hasAttribute('aria-labelledby') ||
74
+ !!input.labels?.length ||
75
+ input.hasAttribute('title');
76
+ if (!inputNamed && typeof console !== 'undefined') {
77
+ console.warn(
78
+ '[bronto] initCombobox(): the combobox input has no accessible name — add a <label>, aria-label, or aria-labelledby (a placeholder is not enough).',
79
+ );
80
+ }
41
81
  input.setAttribute('role', 'combobox');
42
82
  input.setAttribute('aria-controls', listId);
43
83
  input.setAttribute('aria-autocomplete', 'list');
@@ -53,13 +93,7 @@ export function initCombobox({ root } = {}) {
53
93
  if (opt) {
54
94
  opt.classList.add('is-active');
55
95
  input.setAttribute('aria-activedescendant', opt.id);
56
- // jsdom's scrollIntoView throws "Not implemented"; it is a
57
- // pure affordance, so never let it break keyboard nav.
58
- try {
59
- opt.scrollIntoView({ block: 'nearest' });
60
- } catch {
61
- /* non-DOM/headless environment — ignore */
62
- }
96
+ scrollIntoViewSafe(opt);
63
97
  } else {
64
98
  input.removeAttribute('aria-activedescendant');
65
99
  }
@@ -112,10 +146,7 @@ export function initCombobox({ root } = {}) {
112
146
  const vis = visible();
113
147
  if (!vis.length) return;
114
148
  open();
115
- const curIdx = vis.indexOf(options[active]);
116
- let next = curIdx + delta;
117
- if (next < 0) next = vis.length - 1;
118
- if (next >= vis.length) next = 0;
149
+ const next = wrapIndex(vis.indexOf(options[active]), delta, vis.length);
119
150
  active = options.indexOf(vis[next]);
120
151
  setActive(options[active]);
121
152
  };