@adia-ai/web-components 0.2.2 → 0.2.4

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 (121) hide show
  1. package/components/agent-trace/agent-trace.css +24 -3
  2. package/components/button/button.js +3 -0
  3. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  4. package/components/demo-toggle/demo-toggle.css +120 -0
  5. package/components/demo-toggle/demo-toggle.js +144 -0
  6. package/components/demo-toggle/demo-toggle.test.js +102 -0
  7. package/components/demo-toggle/demo-toggle.yaml +144 -0
  8. package/components/index.js +1 -0
  9. package/components/input/input.js +11 -0
  10. package/components/list/list.css +66 -3
  11. package/components/nav-group/nav-group.a2ui.json +1 -1
  12. package/components/nav-group/nav-group.css +5 -5
  13. package/components/nav-group/nav-group.yaml +1 -1
  14. package/components/nav-item/nav-item.a2ui.json +1 -1
  15. package/components/nav-item/nav-item.css +3 -4
  16. package/components/nav-item/nav-item.yaml +1 -1
  17. package/components/textarea/textarea.js +10 -0
  18. package/core/icons.js +13 -1
  19. package/package.json +1 -1
  20. package/styles/components.css +1 -0
  21. package/styles/typography.css +1 -1
  22. package/traits/_catalog.json +258 -5
  23. package/traits/active-state.test.js +1 -1
  24. package/traits/anchor-positioning.js +205 -52
  25. package/traits/anchor-positioning.test.js +77 -4
  26. package/traits/announcer-stage.js +157 -0
  27. package/traits/announcer.js +145 -0
  28. package/traits/announcer.test.js +268 -0
  29. package/traits/arrow-grid-nav.js +234 -0
  30. package/traits/arrow-grid-nav.test.js +375 -0
  31. package/traits/attention-pulse.js +1 -1
  32. package/traits/attention-pulse.test.js +1 -1
  33. package/traits/confetti-burst.js +90 -60
  34. package/traits/confetti-burst.test.js +16 -8
  35. package/traits/confetti-stage.js +143 -0
  36. package/traits/confetti.js +44 -47
  37. package/traits/confetti.test.js +24 -5
  38. package/traits/count-up.js +31 -6
  39. package/traits/count-up.test.js +1 -1
  40. package/traits/declarative.test.js +1 -1
  41. package/traits/dirty-state.test.js +1 -1
  42. package/traits/drag-ghost.js +55 -3
  43. package/traits/drag-ghost.test.js +1 -1
  44. package/traits/draggable-list-item.js +279 -0
  45. package/traits/draggable-list-item.test.js +51 -0
  46. package/traits/draggable.js +14 -4
  47. package/traits/draggable.test.js +1 -1
  48. package/traits/drop-target.js +223 -0
  49. package/traits/drop-target.test.js +241 -0
  50. package/traits/droppable-collection.js +89 -0
  51. package/traits/droppable-collection.test.js +99 -0
  52. package/traits/droppable.js +125 -0
  53. package/traits/droppable.test.js +54 -0
  54. package/traits/error-shake.js +157 -0
  55. package/traits/error-shake.test.js +114 -0
  56. package/traits/fade-presence.test.js +1 -1
  57. package/traits/focus-restore.js +135 -0
  58. package/traits/focus-restore.test.js +202 -0
  59. package/traits/focus-trap.test.js +1 -1
  60. package/traits/focusable.test.js +1 -1
  61. package/traits/glow-focus.js +1 -1
  62. package/traits/glow-focus.test.js +1 -1
  63. package/traits/gradient-shift.js +1 -1
  64. package/traits/gradient-shift.test.js +1 -1
  65. package/traits/haptic-feedback.test.js +1 -1
  66. package/traits/hotkey.test.js +1 -1
  67. package/traits/hoverable.test.js +1 -1
  68. package/traits/index.js +15 -0
  69. package/traits/inertia-drag.js +9 -0
  70. package/traits/inertia-drag.test.js +1 -1
  71. package/traits/input-mask.js +328 -0
  72. package/traits/input-mask.test.js +151 -0
  73. package/traits/intersection-observer.test.js +1 -1
  74. package/traits/keyboard-nav.test.js +1 -1
  75. package/traits/keyboard-reorderable.js +254 -0
  76. package/traits/keyboard-reorderable.test.js +45 -0
  77. package/traits/layout-animation.js +229 -0
  78. package/traits/layout-animation.test.js +114 -0
  79. package/traits/long-press.js +212 -0
  80. package/traits/long-press.test.js +244 -0
  81. package/traits/magnetic-hover.js +1 -1
  82. package/traits/magnetic-hover.test.js +1 -1
  83. package/traits/noise-texture.js +7 -3
  84. package/traits/noise-texture.test.js +1 -1
  85. package/traits/parallax.js +1 -1
  86. package/traits/parallax.test.js +1 -1
  87. package/traits/portal.test.js +1 -1
  88. package/traits/pressable.test.js +1 -1
  89. package/traits/resettable.js +29 -3
  90. package/traits/resettable.test.js +34 -1
  91. package/traits/resizable.test.js +1 -1
  92. package/traits/resize-observer.test.js +1 -1
  93. package/traits/ripple.js +1 -1
  94. package/traits/ripple.test.js +1 -1
  95. package/traits/roving-tabindex.test.js +1 -1
  96. package/traits/scale-press.test.js +1 -1
  97. package/traits/scroll-lock.test.js +1 -1
  98. package/traits/scroll-progress.js +201 -0
  99. package/traits/scroll-progress.test.js +182 -0
  100. package/traits/shimmer-loading.js +1 -1
  101. package/traits/shimmer-loading.test.js +1 -1
  102. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  103. package/traits/snap-to-grid.test.js +1 -1
  104. package/traits/sound-feedback.test.js +1 -1
  105. package/traits/spring-animate.js +8 -3
  106. package/traits/spring-animate.test.js +1 -1
  107. package/traits/success-checkmark.js +222 -0
  108. package/traits/success-checkmark.test.js +120 -0
  109. package/traits/tilt-hover.js +1 -1
  110. package/traits/tilt-hover.test.js +1 -1
  111. package/traits/tossable.js +9 -0
  112. package/traits/tossable.test.js +1 -1
  113. package/traits/traits-host.test.js +1 -1
  114. package/traits/typeahead.test.js +1 -1
  115. package/traits/typewriter.js +1 -1
  116. package/traits/typewriter.test.js +1 -1
  117. package/traits/validation.test.js +1 -1
  118. package/traits/view-transition.js +140 -0
  119. package/traits/view-transition.test.js +268 -0
  120. /package/traits/{_motion.js → motion.js} +0 -0
  121. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "generated": "scripts/build/traits-catalog.mjs",
3
3
  "note": "Generated from packages/web-components/traits/*.js. Do not hand-edit. Run `node scripts/build/traits-catalog.mjs`.",
4
- "count": 41,
4
+ "count": 56,
5
5
  "categories": [
6
6
  "animation-feedback",
7
7
  "audio-haptics-sensory",
@@ -14,6 +14,22 @@
14
14
  "visual-dynamics"
15
15
  ],
16
16
  "traits": [
17
+ {
18
+ "name": "error-shake",
19
+ "category": "animation-feedback",
20
+ "description": "Horizontal lateral oscillation on validation failure",
21
+ "attributes": [
22
+ "data-error-shake-active"
23
+ ],
24
+ "events": [
25
+ "error-shake-done"
26
+ ],
27
+ "config": [
28
+ "data-error-shake-amplitude",
29
+ "data-error-shake-duration",
30
+ "data-error-shake-trigger"
31
+ ]
32
+ },
17
33
  {
18
34
  "name": "fade-presence",
19
35
  "category": "animation-feedback",
@@ -61,6 +77,21 @@
61
77
  "data-spring-damping"
62
78
  ]
63
79
  },
80
+ {
81
+ "name": "success-checkmark",
82
+ "category": "animation-feedback",
83
+ "description": "Stroke-draw checkmark on validation success",
84
+ "attributes": [
85
+ "data-success-checkmark-active"
86
+ ],
87
+ "events": [
88
+ "success-checkmark-done"
89
+ ],
90
+ "config": [
91
+ "data-success-checkmark-position",
92
+ "data-success-checkmark-trigger"
93
+ ]
94
+ },
64
95
  {
65
96
  "name": "tilt-hover",
66
97
  "category": "animation-feedback",
@@ -71,6 +102,23 @@
71
102
  "events": [],
72
103
  "config": []
73
104
  },
105
+ {
106
+ "name": "announcer",
107
+ "category": "audio-haptics-sensory",
108
+ "description": "aria-live mirror for AT — announces host state changes via singleton polite/assertive regions",
109
+ "attributes": [
110
+ "data-announcer-active"
111
+ ],
112
+ "events": [
113
+ "announcement-made"
114
+ ],
115
+ "config": [
116
+ "data-announce-on",
117
+ "data-announce-message",
118
+ "data-announce-priority",
119
+ "data-announce-throttle"
120
+ ]
121
+ },
74
122
  {
75
123
  "name": "attention-pulse",
76
124
  "category": "audio-haptics-sensory",
@@ -95,7 +143,11 @@
95
143
  ],
96
144
  "config": [
97
145
  "data-count-up-target",
98
- "data-count-duration"
146
+ "data-count-duration",
147
+ "data-count-prefix",
148
+ "data-count-suffix",
149
+ "data-count-decimals",
150
+ "data-count-locale"
99
151
  ]
100
152
  },
101
153
  {
@@ -147,6 +199,22 @@
147
199
  "events": [],
148
200
  "config": []
149
201
  },
202
+ {
203
+ "name": "input-mask",
204
+ "category": "forms-data",
205
+ "description": "Locale-aware as-you-type formatter (phone, credit-card, date, currency)",
206
+ "attributes": [
207
+ "data-input-mask-active",
208
+ "data-input-mask-complete"
209
+ ],
210
+ "events": [
211
+ "mask-commit"
212
+ ],
213
+ "config": [
214
+ "data-mask-pattern",
215
+ "data-mask-strip-on-commit"
216
+ ]
217
+ },
150
218
  {
151
219
  "name": "resettable",
152
220
  "category": "forms-data",
@@ -185,6 +253,35 @@
185
253
  "events": [],
186
254
  "config": []
187
255
  },
256
+ {
257
+ "name": "droppable",
258
+ "category": "input-interaction",
259
+ "description": "Marks an element as a drop target for draggable-list-item; emits dnd-drop-enter / leave / receive",
260
+ "attributes": [
261
+ "data-droppable-id",
262
+ "data-droppable-over",
263
+ "data-droppable-valid"
264
+ ],
265
+ "events": [
266
+ "dnd-drop-enter",
267
+ "dnd-drop-leave",
268
+ "dnd-drop-receive"
269
+ ],
270
+ "config": []
271
+ },
272
+ {
273
+ "name": "droppable-collection",
274
+ "category": "input-interaction",
275
+ "description": "Coordinates droppable children; re-emits drops as dnd-collection-drop",
276
+ "attributes": [
277
+ "data-droppable-collection-active",
278
+ "data-droppable-collection-dragging"
279
+ ],
280
+ "events": [
281
+ "dnd-collection-drop"
282
+ ],
283
+ "config": []
284
+ },
188
285
  {
189
286
  "name": "focusable",
190
287
  "category": "input-interaction",
@@ -205,6 +302,26 @@
205
302
  "events": [],
206
303
  "config": []
207
304
  },
305
+ {
306
+ "name": "long-press",
307
+ "category": "input-interaction",
308
+ "description": "Press-and-hold trigger: fires after configurable duration with progress events",
309
+ "attributes": [
310
+ "data-long-press-active",
311
+ "data-long-press-progress",
312
+ "data-long-press-fired"
313
+ ],
314
+ "events": [
315
+ "long-press",
316
+ "long-press-cancelled",
317
+ "long-press-progress"
318
+ ],
319
+ "config": [
320
+ "data-long-press-duration",
321
+ "data-long-press-tolerance",
322
+ "data-long-press-progress-interval"
323
+ ]
324
+ },
208
325
  {
209
326
  "name": "pressable",
210
327
  "category": "input-interaction",
@@ -230,7 +347,7 @@
230
347
  {
231
348
  "name": "confetti-burst",
232
349
  "category": "interaction-delight",
233
- "description": "Upward fountain particle burst",
350
+ "description": "Upward fountain particle burst — fires on each `press` event",
234
351
  "attributes": [
235
352
  "data-confetti-burst-active"
236
353
  ],
@@ -249,6 +366,39 @@
249
366
  "events": [],
250
367
  "config": []
251
368
  },
369
+ {
370
+ "name": "arrow-grid-nav",
371
+ "category": "keyboard-navigation",
372
+ "description": "2D arrow-key navigation for grids, calendars, menubars (APG grid pattern)",
373
+ "attributes": [
374
+ "data-arrow-grid-nav-active",
375
+ "data-grid-active-row",
376
+ "data-grid-active-col"
377
+ ],
378
+ "events": [
379
+ "grid-activate",
380
+ "grid-edge",
381
+ "grid-cell-change"
382
+ ],
383
+ "config": [
384
+ "data-grid-columns",
385
+ "data-grid-mode"
386
+ ]
387
+ },
388
+ {
389
+ "name": "focus-restore",
390
+ "category": "keyboard-navigation",
391
+ "description": "Capture previously-focused element on connect, restore focus on disconnect",
392
+ "attributes": [
393
+ "data-focus-restore-active"
394
+ ],
395
+ "events": [
396
+ "focus-restored"
397
+ ],
398
+ "config": [
399
+ "data-focus-restore-on-mount"
400
+ ]
401
+ },
252
402
  {
253
403
  "name": "focus-trap",
254
404
  "category": "keyboard-navigation",
@@ -291,6 +441,22 @@
291
441
  ],
292
442
  "config": []
293
443
  },
444
+ {
445
+ "name": "keyboard-reorderable",
446
+ "category": "keyboard-navigation",
447
+ "description": "Keyboard alternative to draggable-list-item: arrow keys + Space to lift / drop / Esc to cancel",
448
+ "attributes": [
449
+ "data-keyboard-reorderable-lifting",
450
+ "data-keyboard-reorderable-id"
451
+ ],
452
+ "events": [
453
+ "dnd-lift",
454
+ "dnd-drop-target-change",
455
+ "dnd-drop",
456
+ "dnd-drop-cancel"
457
+ ],
458
+ "config": []
459
+ },
294
460
  {
295
461
  "name": "roving-tabindex",
296
462
  "category": "keyboard-navigation",
@@ -317,7 +483,8 @@
317
483
  "description": "Positions relative to an anchor element",
318
484
  "attributes": [
319
485
  "data-anchor-positioning-placed",
320
- "data-anchor-placement-actual"
486
+ "data-anchor-placement-actual",
487
+ "data-anchor-mode"
321
488
  ],
322
489
  "events": [
323
490
  "anchor-placed"
@@ -380,6 +547,22 @@
380
547
  "events": [],
381
548
  "config": []
382
549
  },
550
+ {
551
+ "name": "scroll-progress",
552
+ "category": "layout-measurement",
553
+ "description": "Page or element scroll progress as 0..1 attribute + CSS variable + event",
554
+ "attributes": [
555
+ "data-scroll-progress-active",
556
+ "data-scroll-progress"
557
+ ],
558
+ "events": [
559
+ "scroll-progress"
560
+ ],
561
+ "config": [
562
+ "data-scroll-progress-mode",
563
+ "data-scroll-container"
564
+ ]
565
+ },
383
566
  {
384
567
  "name": "drag-ghost",
385
568
  "category": "motion-positioning",
@@ -402,6 +585,41 @@
402
585
  ],
403
586
  "config": []
404
587
  },
588
+ {
589
+ "name": "draggable-list-item",
590
+ "category": "motion-positioning",
591
+ "description": "Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel",
592
+ "attributes": [
593
+ "data-draggable-list-item-lifting",
594
+ "data-draggable-list-item-id"
595
+ ],
596
+ "events": [
597
+ "dnd-lift",
598
+ "dnd-drop-target-change",
599
+ "dnd-drop",
600
+ "dnd-drop-cancel"
601
+ ],
602
+ "config": []
603
+ },
604
+ {
605
+ "name": "drop-target",
606
+ "category": "motion-positioning",
607
+ "description": "Declarative drop zone: hit-testing, accept-reject, drag-over feedback",
608
+ "attributes": [
609
+ "data-drop-target-active",
610
+ "data-drop-target-over",
611
+ "data-drop-target-rejected"
612
+ ],
613
+ "events": [
614
+ "drop-enter",
615
+ "drop-leave",
616
+ "drop-receive",
617
+ "drop-rejected"
618
+ ],
619
+ "config": [
620
+ "data-drop-target-accepts"
621
+ ]
622
+ },
405
623
  {
406
624
  "name": "inertia-drag",
407
625
  "category": "motion-positioning",
@@ -414,6 +632,22 @@
414
632
  ],
415
633
  "config": []
416
634
  },
635
+ {
636
+ "name": "layout-animation",
637
+ "category": "motion-positioning",
638
+ "description": "FLIP-style layout transition: animate from old to new bounds without explicit coordinates",
639
+ "attributes": [
640
+ "data-layout-animation-active"
641
+ ],
642
+ "events": [
643
+ "layout-animation-done"
644
+ ],
645
+ "config": [
646
+ "data-layout-animate-duration",
647
+ "data-layout-animate-easing",
648
+ "data-layout-animate-trigger"
649
+ ]
650
+ },
417
651
  {
418
652
  "name": "resizable",
419
653
  "category": "motion-positioning",
@@ -453,6 +687,23 @@
453
687
  "data-tossable-bounds"
454
688
  ]
455
689
  },
690
+ {
691
+ "name": "view-transition",
692
+ "category": "motion-positioning",
693
+ "description": "Wraps document.startViewTransition() for morph animations between DOM states",
694
+ "attributes": [
695
+ "data-view-transition-active"
696
+ ],
697
+ "events": [
698
+ "view-transition-start",
699
+ "view-transition-end"
700
+ ],
701
+ "config": [
702
+ "data-view-transition-name",
703
+ "data-view-transition-duration",
704
+ "data-view-transition-easing"
705
+ ]
706
+ },
456
707
  {
457
708
  "name": "glow-focus",
458
709
  "category": "visual-dynamics",
@@ -481,7 +732,9 @@
481
732
  "data-noise-texture-active"
482
733
  ],
483
734
  "events": [],
484
- "config": []
735
+ "config": [
736
+ "data-noise-strength"
737
+ ]
485
738
  },
486
739
  {
487
740
  "name": "parallax",
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { activeState } from './active-state.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('active-state', () => {
6
6
  beforeEach(resetDOM);
@@ -1,10 +1,48 @@
1
+ /**
2
+ * anchor-positioning — pin a host element to a named anchor.
3
+ *
4
+ * Native path (Chromium 125+, Safari 18.0+):
5
+ * - Promotes the host into the top layer via `popover="manual"` +
6
+ * `host.showPopover()` so it escapes any `overflow: hidden` ancestor.
7
+ * - Names the anchor with `anchor-name: --anchor-{slug}` and points the
8
+ * host at it with `position-anchor: --anchor-{slug}`.
9
+ * - Lays out via `position-area`; the browser drives reflow on its own,
10
+ * no JS scroll/resize loop needed.
11
+ *
12
+ * Fallback path (Firefox 129+ today, Safari < 18.0):
13
+ * - Plain `position: fixed` + measured top/left from getBoundingClientRect.
14
+ * - scroll (capture) + resize listeners drive re-layout.
15
+ * - Mirrors the v0 behavior so the public attribute API is unchanged.
16
+ *
17
+ * `data-anchor-mode="native"|"fallback"` is reflected on the host so
18
+ * consumers and DevTools sessions can see which path actually ran.
19
+ */
20
+
1
21
  import { defineTrait } from './define.js';
2
22
 
23
+ /**
24
+ * Feature-detect both halves of the API we depend on. Chrome 125+ and
25
+ * Safari 18.0+ pass both; Firefox 129's partial implementation typically
26
+ * fails the `position-area` half and lands on the fallback. Mirrors the
27
+ * detection used in core/anchor.js so the trait + the popover helper
28
+ * agree on which path ran.
29
+ */
30
+ const supportsNative =
31
+ typeof CSS !== 'undefined' &&
32
+ (CSS.supports?.('anchor-name', '--x') ?? false) &&
33
+ (CSS.supports?.('position-area', 'bottom') ?? false);
34
+
35
+ let anchorIdCounter = 0;
36
+
3
37
  export const anchorPositioning = defineTrait({
4
38
  name: 'anchor-positioning',
5
39
  category: 'layout-measurement',
6
40
  description: 'Positions relative to an anchor element',
7
- attributes: ['data-anchor-positioning-placed', 'data-anchor-placement-actual'],
41
+ attributes: [
42
+ 'data-anchor-positioning-placed',
43
+ 'data-anchor-placement-actual',
44
+ 'data-anchor-mode',
45
+ ],
8
46
  events: ['anchor-placed'],
9
47
  config: ['data-anchor', 'data-anchor-placement', 'data-anchor-gap'],
10
48
  setup({ host }) {
@@ -12,57 +50,172 @@ export const anchorPositioning = defineTrait({
12
50
  const placement = host.getAttribute('data-anchor-placement') || 'bottom';
13
51
  const gap = parseInt(host.getAttribute('data-anchor-gap'), 10) || 0;
14
52
 
15
- function position() {
16
- const anchor = document.querySelector(anchorSel) ||
17
- document.getElementById(anchorSel);
18
- if (!anchor) return;
19
-
20
- const ar = anchor.getBoundingClientRect();
21
- const hr = host.getBoundingClientRect();
22
- const vw = window.innerWidth;
23
- const vh = window.innerHeight;
24
-
25
- let top, left;
26
- let actual = placement;
27
-
28
- if (placement.startsWith('bottom')) {
29
- top = ar.bottom + gap;
30
- left = ar.left + (ar.width - hr.width) / 2;
31
- if (top + hr.height > vh) { top = ar.top - hr.height - gap; actual = 'top'; }
32
- } else if (placement.startsWith('top')) {
33
- top = ar.top - hr.height - gap;
34
- left = ar.left + (ar.width - hr.width) / 2;
35
- if (top < 0) { top = ar.bottom + gap; actual = 'bottom'; }
36
- } else if (placement.startsWith('left')) {
37
- top = ar.top + (ar.height - hr.height) / 2;
38
- left = ar.left - hr.width - gap;
39
- if (left < 0) { left = ar.right + gap; actual = 'right'; }
40
- } else {
41
- top = ar.top + (ar.height - hr.height) / 2;
42
- left = ar.right + gap;
43
- if (left + hr.width > vw) { left = ar.left - hr.width - gap; actual = 'left'; }
44
- }
45
-
46
- left = Math.max(0, Math.min(left, vw - hr.width));
47
- top = Math.max(0, Math.min(top, vh - hr.height));
48
-
49
- host.style.position = 'fixed';
50
- host.style.top = `${top}px`;
51
- host.style.left = `${left}px`;
52
- host.setAttribute('data-anchor-positioning-placed', '');
53
- host.setAttribute('data-anchor-placement-actual', actual);
54
- host.dispatchEvent(new CustomEvent('anchor-placed', { bubbles: true, detail: { actual } }));
55
- }
53
+ const anchor = resolveAnchor(anchorSel);
54
+ if (!anchor) return () => {};
56
55
 
57
- position();
58
- window.addEventListener('scroll', position, true);
59
- window.addEventListener('resize', position);
60
-
61
- return () => {
62
- window.removeEventListener('scroll', position, true);
63
- window.removeEventListener('resize', position);
64
- host.removeAttribute('data-anchor-positioning-placed');
65
- host.removeAttribute('data-anchor-placement-actual');
66
- };
56
+ return supportsNative
57
+ ? setupNative({ host, anchor, placement, gap })
58
+ : setupFallback({ host, anchor, placement, gap });
67
59
  },
68
60
  });
61
+
62
+ function resolveAnchor(sel) {
63
+ if (!sel) return null;
64
+ try {
65
+ return document.querySelector(sel) || document.getElementById(sel);
66
+ } catch (_) {
67
+ // Bare ids ("anchor-x" without a leading "#") throw on querySelector.
68
+ return document.getElementById(sel);
69
+ }
70
+ }
71
+
72
+ // ── Native path ─────────────────────────────────────────────────────────
73
+
74
+ function setupNative({ host, anchor, placement, gap }) {
75
+ const name = `--anchor-${++anchorIdCounter}`;
76
+ const prevAnchorName = anchor.style.anchorName;
77
+ const prevPopover = host.getAttribute('popover');
78
+
79
+ // Tag the anchor + the host so the layout engine can wire them up.
80
+ anchor.style.anchorName = name;
81
+ host.style.position = 'fixed';
82
+ host.style.positionAnchor = name;
83
+ host.style.positionArea = placementToPositionArea(placement);
84
+ host.style.margin = placementToGapMargin(placement, gap);
85
+ // Clear any v0 fallback-leftover coordinates so they don't fight CSS.
86
+ host.style.top = '';
87
+ host.style.left = '';
88
+ // Let the browser flip across either axis when the requested edge clips.
89
+ host.style.positionTryFallbacks = 'flip-block, flip-inline, flip-block flip-inline';
90
+
91
+ // Promote into the top layer so overflow:hidden ancestors can't clip.
92
+ if (!prevPopover) host.setAttribute('popover', 'manual');
93
+ let didShow = false;
94
+ try {
95
+ host.showPopover();
96
+ didShow = true;
97
+ } catch (_) {
98
+ // Already-shown / unsupported in the test env — both are non-fatal.
99
+ }
100
+
101
+ host.setAttribute('data-anchor-mode', 'native');
102
+ host.setAttribute('data-anchor-positioning-placed', '');
103
+ host.setAttribute('data-anchor-placement-actual', placement);
104
+ host.dispatchEvent(new CustomEvent('anchor-placed', {
105
+ bubbles: true,
106
+ detail: { actual: placement, mode: 'native' },
107
+ }));
108
+
109
+ return () => {
110
+ anchor.style.anchorName = prevAnchorName;
111
+ host.style.positionAnchor = '';
112
+ host.style.positionArea = '';
113
+ host.style.positionTryFallbacks = '';
114
+ host.style.margin = '';
115
+ if (didShow) {
116
+ try { host.hidePopover(); } catch (_) { /* already-hidden / unsupported */ }
117
+ }
118
+ if (!prevPopover) host.removeAttribute('popover');
119
+ host.removeAttribute('data-anchor-mode');
120
+ host.removeAttribute('data-anchor-positioning-placed');
121
+ host.removeAttribute('data-anchor-placement-actual');
122
+ };
123
+ }
124
+
125
+ // ── Fallback path ───────────────────────────────────────────────────────
126
+
127
+ function setupFallback({ host, anchor, placement, gap }) {
128
+ function position() {
129
+ const ar = anchor.getBoundingClientRect();
130
+ const hr = host.getBoundingClientRect();
131
+ const vw = window.innerWidth;
132
+ const vh = window.innerHeight;
133
+
134
+ let top, left;
135
+ let actual = placement;
136
+
137
+ if (placement.startsWith('bottom')) {
138
+ top = ar.bottom + gap;
139
+ left = ar.left + (ar.width - hr.width) / 2;
140
+ if (top + hr.height > vh) { top = ar.top - hr.height - gap; actual = 'top'; }
141
+ } else if (placement.startsWith('top')) {
142
+ top = ar.top - hr.height - gap;
143
+ left = ar.left + (ar.width - hr.width) / 2;
144
+ if (top < 0) { top = ar.bottom + gap; actual = 'bottom'; }
145
+ } else if (placement.startsWith('left')) {
146
+ top = ar.top + (ar.height - hr.height) / 2;
147
+ left = ar.left - hr.width - gap;
148
+ if (left < 0) { left = ar.right + gap; actual = 'right'; }
149
+ } else {
150
+ top = ar.top + (ar.height - hr.height) / 2;
151
+ left = ar.right + gap;
152
+ if (left + hr.width > vw) { left = ar.left - hr.width - gap; actual = 'left'; }
153
+ }
154
+
155
+ left = Math.max(0, Math.min(left, vw - hr.width));
156
+ top = Math.max(0, Math.min(top, vh - hr.height));
157
+
158
+ host.style.position = 'fixed';
159
+ host.style.top = `${top}px`;
160
+ host.style.left = `${left}px`;
161
+ host.setAttribute('data-anchor-positioning-placed', '');
162
+ host.setAttribute('data-anchor-placement-actual', actual);
163
+ host.dispatchEvent(new CustomEvent('anchor-placed', {
164
+ bubbles: true,
165
+ detail: { actual, mode: 'fallback' },
166
+ }));
167
+ }
168
+
169
+ host.setAttribute('data-anchor-mode', 'fallback');
170
+ position();
171
+ window.addEventListener('scroll', position, true);
172
+ window.addEventListener('resize', position);
173
+
174
+ return () => {
175
+ window.removeEventListener('scroll', position, true);
176
+ window.removeEventListener('resize', position);
177
+ host.removeAttribute('data-anchor-mode');
178
+ host.removeAttribute('data-anchor-positioning-placed');
179
+ host.removeAttribute('data-anchor-placement-actual');
180
+ };
181
+ }
182
+
183
+ // ── Placement → CSS helpers ─────────────────────────────────────────────
184
+
185
+ /**
186
+ * Map our placement vocabulary (top|bottom|left|right + -start|-end) to
187
+ * the CSS `position-area` keyword pair that produces equivalent layout.
188
+ *
189
+ * The bare cardinals span the cross-axis ("span-all") so the host
190
+ * centers; the -start / -end variants pin to the named edge.
191
+ */
192
+ function placementToPositionArea(placement) {
193
+ switch (placement) {
194
+ case 'bottom': return 'bottom span-all';
195
+ case 'bottom-start': return 'bottom span-right';
196
+ case 'bottom-end': return 'bottom span-left';
197
+ case 'top': return 'top span-all';
198
+ case 'top-start': return 'top span-right';
199
+ case 'top-end': return 'top span-left';
200
+ case 'left': return 'left span-all';
201
+ case 'left-start': return 'left span-bottom';
202
+ case 'left-end': return 'left span-top';
203
+ case 'right': return 'right span-all';
204
+ case 'right-start': return 'right span-bottom';
205
+ case 'right-end': return 'right span-top';
206
+ default: return 'bottom span-all';
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Apply gap on the main (anchor-adjacent) axis only — the cross-axis
212
+ * spans the anchor and shouldn't carry margin or alignment will drift.
213
+ */
214
+ function placementToGapMargin(placement, gap) {
215
+ if (!gap) return '';
216
+ if (placement.startsWith('bottom')) return `${gap}px 0 0 0`;
217
+ if (placement.startsWith('top')) return `0 0 ${gap}px 0`;
218
+ if (placement.startsWith('left')) return `0 ${gap}px 0 0`;
219
+ if (placement.startsWith('right')) return `0 0 0 ${gap}px`;
220
+ return `${gap}px`;
221
+ }