@adia-ai/web-components 0.2.3 → 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 (113) hide show
  1. package/components/button/button.js +3 -0
  2. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  3. package/components/demo-toggle/demo-toggle.css +120 -0
  4. package/components/demo-toggle/demo-toggle.js +144 -0
  5. package/components/demo-toggle/demo-toggle.test.js +102 -0
  6. package/components/demo-toggle/demo-toggle.yaml +144 -0
  7. package/components/index.js +1 -0
  8. package/components/input/input.js +11 -0
  9. package/components/list/list.css +21 -0
  10. package/components/textarea/textarea.js +10 -0
  11. package/core/icons.js +12 -1
  12. package/package.json +1 -1
  13. package/styles/components.css +1 -0
  14. package/styles/typography.css +1 -1
  15. package/traits/_catalog.json +257 -4
  16. package/traits/active-state.test.js +1 -1
  17. package/traits/anchor-positioning.js +205 -52
  18. package/traits/anchor-positioning.test.js +77 -4
  19. package/traits/announcer-stage.js +157 -0
  20. package/traits/announcer.js +145 -0
  21. package/traits/announcer.test.js +268 -0
  22. package/traits/arrow-grid-nav.js +234 -0
  23. package/traits/arrow-grid-nav.test.js +375 -0
  24. package/traits/attention-pulse.js +1 -1
  25. package/traits/attention-pulse.test.js +1 -1
  26. package/traits/confetti-burst.js +67 -63
  27. package/traits/confetti-burst.test.js +16 -8
  28. package/traits/confetti-stage.js +143 -0
  29. package/traits/confetti.js +44 -47
  30. package/traits/confetti.test.js +24 -5
  31. package/traits/count-up.js +31 -6
  32. package/traits/count-up.test.js +1 -1
  33. package/traits/declarative.test.js +1 -1
  34. package/traits/dirty-state.test.js +1 -1
  35. package/traits/drag-ghost.js +43 -3
  36. package/traits/drag-ghost.test.js +1 -1
  37. package/traits/draggable-list-item.js +279 -0
  38. package/traits/draggable-list-item.test.js +51 -0
  39. package/traits/draggable.js +14 -4
  40. package/traits/draggable.test.js +1 -1
  41. package/traits/drop-target.js +223 -0
  42. package/traits/drop-target.test.js +241 -0
  43. package/traits/droppable-collection.js +89 -0
  44. package/traits/droppable-collection.test.js +99 -0
  45. package/traits/droppable.js +125 -0
  46. package/traits/droppable.test.js +54 -0
  47. package/traits/error-shake.js +157 -0
  48. package/traits/error-shake.test.js +114 -0
  49. package/traits/fade-presence.test.js +1 -1
  50. package/traits/focus-restore.js +135 -0
  51. package/traits/focus-restore.test.js +202 -0
  52. package/traits/focus-trap.test.js +1 -1
  53. package/traits/focusable.test.js +1 -1
  54. package/traits/glow-focus.js +1 -1
  55. package/traits/glow-focus.test.js +1 -1
  56. package/traits/gradient-shift.js +1 -1
  57. package/traits/gradient-shift.test.js +1 -1
  58. package/traits/haptic-feedback.test.js +1 -1
  59. package/traits/hotkey.test.js +1 -1
  60. package/traits/hoverable.test.js +1 -1
  61. package/traits/index.js +15 -0
  62. package/traits/inertia-drag.js +9 -0
  63. package/traits/inertia-drag.test.js +1 -1
  64. package/traits/input-mask.js +328 -0
  65. package/traits/input-mask.test.js +151 -0
  66. package/traits/intersection-observer.test.js +1 -1
  67. package/traits/keyboard-nav.test.js +1 -1
  68. package/traits/keyboard-reorderable.js +254 -0
  69. package/traits/keyboard-reorderable.test.js +45 -0
  70. package/traits/layout-animation.js +229 -0
  71. package/traits/layout-animation.test.js +114 -0
  72. package/traits/long-press.js +212 -0
  73. package/traits/long-press.test.js +244 -0
  74. package/traits/magnetic-hover.js +1 -1
  75. package/traits/magnetic-hover.test.js +1 -1
  76. package/traits/noise-texture.js +7 -3
  77. package/traits/noise-texture.test.js +1 -1
  78. package/traits/parallax.js +1 -1
  79. package/traits/parallax.test.js +1 -1
  80. package/traits/portal.test.js +1 -1
  81. package/traits/pressable.test.js +1 -1
  82. package/traits/resettable.js +29 -3
  83. package/traits/resettable.test.js +34 -1
  84. package/traits/resizable.test.js +1 -1
  85. package/traits/resize-observer.test.js +1 -1
  86. package/traits/ripple.js +1 -1
  87. package/traits/ripple.test.js +1 -1
  88. package/traits/roving-tabindex.test.js +1 -1
  89. package/traits/scale-press.test.js +1 -1
  90. package/traits/scroll-lock.test.js +1 -1
  91. package/traits/scroll-progress.js +201 -0
  92. package/traits/scroll-progress.test.js +182 -0
  93. package/traits/shimmer-loading.js +1 -1
  94. package/traits/shimmer-loading.test.js +1 -1
  95. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  96. package/traits/snap-to-grid.test.js +1 -1
  97. package/traits/sound-feedback.test.js +1 -1
  98. package/traits/spring-animate.test.js +1 -1
  99. package/traits/success-checkmark.js +222 -0
  100. package/traits/success-checkmark.test.js +120 -0
  101. package/traits/tilt-hover.js +1 -1
  102. package/traits/tilt-hover.test.js +1 -1
  103. package/traits/tossable.js +9 -0
  104. package/traits/tossable.test.js +1 -1
  105. package/traits/traits-host.test.js +1 -1
  106. package/traits/typeahead.test.js +1 -1
  107. package/traits/typewriter.js +1 -1
  108. package/traits/typewriter.test.js +1 -1
  109. package/traits/validation.test.js +1 -1
  110. package/traits/view-transition.js +140 -0
  111. package/traits/view-transition.test.js +268 -0
  112. /package/traits/{_motion.js → motion.js} +0 -0
  113. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -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
+ }
@@ -1,11 +1,19 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { anchorPositioning } from './anchor-positioning.js';
3
- import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
4
 
5
+ /**
6
+ * Note on test environment: happy-dom does not implement
7
+ * `CSS.supports('anchor-name', '--x')` or the Popover API surface that
8
+ * actually moves an element into the top layer. Tests therefore land on
9
+ * the fallback path and assert the public contract (attrs, event,
10
+ * connect/disconnect symmetry) — not specific viewport coordinates.
11
+ * Real layout is exercised in the live browser demo.
12
+ */
5
13
  describe('anchor-positioning', () => {
6
14
  beforeEach(resetDOM);
7
15
 
8
- it('connect with valid anchor sets placed + actual attributes', () => {
16
+ it('connect with valid anchor sets placed + actual + mode attributes', () => {
9
17
  const anchor = document.createElement('div');
10
18
  anchor.id = 'anchor-x';
11
19
  document.body.appendChild(anchor);
@@ -16,10 +24,14 @@ describe('anchor-positioning', () => {
16
24
  connectTrait(anchorPositioning, host);
17
25
  expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(true);
18
26
  expect(host.getAttribute('data-anchor-placement-actual')).toBeTruthy();
27
+ expect(host.getAttribute('data-anchor-mode')).toMatch(/^(native|fallback)$/);
28
+ // Both paths set position:fixed — native because that's what the
29
+ // CSS Anchor Positioning helpers expect, fallback because it's the
30
+ // measured-coords positioning context.
19
31
  expect(host.style.position).toBe('fixed');
20
32
  });
21
33
 
22
- it('dispatches anchor-placed event with the actual placement in detail', () => {
34
+ it('dispatches anchor-placed event with the actual placement + mode in detail', () => {
23
35
  const anchor = document.createElement('div');
24
36
  anchor.id = 'anchor-y';
25
37
  document.body.appendChild(anchor);
@@ -28,15 +40,32 @@ describe('anchor-positioning', () => {
28
40
  connectTrait(anchorPositioning, host);
29
41
  expect(spy.count).toBeGreaterThanOrEqual(1);
30
42
  expect(spy.last.actual).toBeTruthy();
43
+ expect(spy.last.mode).toMatch(/^(native|fallback)$/);
44
+ });
45
+
46
+ it('resolves anchor by selector OR by bare id', () => {
47
+ const anchor = document.createElement('div');
48
+ anchor.id = 'bare-id-anchor';
49
+ document.body.appendChild(anchor);
50
+ const host = mountHost('div', { 'data-anchor': 'bare-id-anchor' });
51
+ connectTrait(anchorPositioning, host);
52
+ expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(true);
31
53
  });
32
54
 
33
55
  it('missing anchor: does not throw, does not place', () => {
34
56
  const host = mountHost('div', { 'data-anchor': '#nope' });
35
57
  expect(() => connectTrait(anchorPositioning, host)).not.toThrow();
36
58
  expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(false);
59
+ expect(host.hasAttribute('data-anchor-mode')).toBe(false);
37
60
  });
38
61
 
39
- it('disconnect clears both attributes and stops listening', () => {
62
+ it('missing data-anchor attribute: does not throw, does not place', () => {
63
+ const host = mountHost('div');
64
+ expect(() => connectTrait(anchorPositioning, host)).not.toThrow();
65
+ expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(false);
66
+ });
67
+
68
+ it('disconnect clears all three managed attributes and stops listening', () => {
40
69
  const anchor = document.createElement('div');
41
70
  anchor.id = 'anchor-z';
42
71
  document.body.appendChild(anchor);
@@ -45,5 +74,49 @@ describe('anchor-positioning', () => {
45
74
  inst.disconnect(host);
46
75
  expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(false);
47
76
  expect(host.hasAttribute('data-anchor-placement-actual')).toBe(false);
77
+ expect(host.hasAttribute('data-anchor-mode')).toBe(false);
78
+ });
79
+
80
+ it('connect → disconnect → connect again does not throw', () => {
81
+ const anchor = document.createElement('div');
82
+ anchor.id = 'anchor-recycle';
83
+ document.body.appendChild(anchor);
84
+ const host = mountHost('div', { 'data-anchor': '#anchor-recycle' });
85
+ const inst1 = connectTrait(anchorPositioning, host);
86
+ inst1.disconnect(host);
87
+ expect(() => {
88
+ const inst2 = connectTrait(anchorPositioning, host);
89
+ inst2.disconnect(host);
90
+ }).not.toThrow();
91
+ });
92
+
93
+ it('honors explicit placements (top / left / right)', () => {
94
+ for (const placement of ['top', 'left', 'right', 'bottom-start', 'top-end']) {
95
+ const anchor = document.createElement('div');
96
+ anchor.id = `anchor-${placement}`;
97
+ document.body.appendChild(anchor);
98
+ const host = mountHost('div', {
99
+ 'data-anchor': `#anchor-${placement}`,
100
+ 'data-anchor-placement': placement,
101
+ });
102
+ const inst = connectTrait(anchorPositioning, host);
103
+ expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(true);
104
+ expect(host.getAttribute('data-anchor-placement-actual')).toBeTruthy();
105
+ inst.disconnect(host);
106
+ anchor.remove();
107
+ host.remove();
108
+ }
109
+ });
110
+
111
+ it('honors data-anchor-gap (numeric, defaults to 0)', () => {
112
+ const anchor = document.createElement('div');
113
+ anchor.id = 'anchor-gap';
114
+ document.body.appendChild(anchor);
115
+ const host = mountHost('div', {
116
+ 'data-anchor': '#anchor-gap',
117
+ 'data-anchor-gap': '12',
118
+ });
119
+ expect(() => connectTrait(anchorPositioning, host)).not.toThrow();
120
+ expect(host.hasAttribute('data-anchor-positioning-placed')).toBe(true);
48
121
  });
49
122
  });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Shared aria-live regions for the `announcer` trait.
3
+ *
4
+ * Strategy: two body-level singleton `<div>` elements — one with
5
+ * `aria-live="polite"`, one with `aria-live="assertive"` — both with
6
+ * `aria-atomic="true"` so AT re-reads the full message on every change
7
+ * (instead of diffing the previous value, which can drop short
8
+ * messages or read fragments). The regions are visually clipped via
9
+ * the `.adia-sr-only` class so sighted users never see them, but
10
+ * remain in the accessibility tree so screen readers announce their
11
+ * contents.
12
+ *
13
+ * Both regions are created lazily on first `getRegion()` call and
14
+ * never torn down — they're cheap (two empty divs + one `<style>`)
15
+ * and many traits can share them. Multiple announcer instances on
16
+ * the same page route through the same two regions.
17
+ *
18
+ * The clip-path / sr-only recipe is the canonical "visually hidden,
19
+ * AT-visible" pattern: zero clip-path, position absolute off-screen,
20
+ * 1px size with overflow hidden. WCAG-friendly; works in every
21
+ * browser the project supports (Chromium 125+, Safari 18.0+,
22
+ * Firefox 129+).
23
+ *
24
+ * Notes:
25
+ * - happy-dom does not actually announce anything — these tests assert
26
+ * the *contents* of the region, not the AT speech. Real-browser
27
+ * verification happens on the demo page.
28
+ * - The regions are created on first call from any module, so importing
29
+ * this file is side-effect-free until something invokes `getRegion()`.
30
+ */
31
+
32
+ const POLITE_ID = 'adia-live-polite';
33
+ const ASSERTIVE_ID = 'adia-live-assertive';
34
+ const STYLE_ID = 'adia-live-styles';
35
+
36
+ let politeRegion = null;
37
+ let assertiveRegion = null;
38
+ let stylesInjected = false;
39
+
40
+ /**
41
+ * Get (or lazily create) one of the two singleton live regions.
42
+ * `priority` selects which region: `'polite'` for non-urgent updates
43
+ * (the default), `'assertive'` for urgent messages that should
44
+ * interrupt current speech (validation errors, destructive
45
+ * confirmations).
46
+ *
47
+ * Returns the region element. If `document` is unavailable (SSR,
48
+ * worker), returns `null` — callers should bail.
49
+ */
50
+ export function getRegion(priority = 'polite') {
51
+ if (typeof document === 'undefined') return null;
52
+ ensureStyles();
53
+
54
+ if (priority === 'assertive') {
55
+ if (assertiveRegion && assertiveRegion.isConnected) return assertiveRegion;
56
+ const existing = document.getElementById(ASSERTIVE_ID);
57
+ if (existing) {
58
+ assertiveRegion = existing;
59
+ return assertiveRegion;
60
+ }
61
+ assertiveRegion = createRegion(ASSERTIVE_ID, 'assertive');
62
+ return assertiveRegion;
63
+ }
64
+
65
+ if (politeRegion && politeRegion.isConnected) return politeRegion;
66
+ const existing = document.getElementById(POLITE_ID);
67
+ if (existing) {
68
+ politeRegion = existing;
69
+ return politeRegion;
70
+ }
71
+ politeRegion = createRegion(POLITE_ID, 'polite');
72
+ return politeRegion;
73
+ }
74
+
75
+ function createRegion(id, liveValue) {
76
+ const el = document.createElement('div');
77
+ el.id = id;
78
+ el.setAttribute('aria-live', liveValue);
79
+ el.setAttribute('aria-atomic', 'true');
80
+ el.setAttribute('role', liveValue === 'assertive' ? 'alert' : 'status');
81
+ el.className = 'adia-sr-only';
82
+ // Defensive inline styles — if the consumer's CSS strips the class
83
+ // or the stylesheet failed to inject, the region must still be
84
+ // visually hidden. Belt-and-suspenders for production reliability.
85
+ el.style.cssText =
86
+ 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
87
+ 'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
88
+ document.body.appendChild(el);
89
+ return el;
90
+ }
91
+
92
+ /**
93
+ * Inject the `.adia-sr-only` class once. Idempotent. The class is
94
+ * the canonical "visually hidden, AT-visible" recipe — zero in the
95
+ * visual layout, full presence in the accessibility tree.
96
+ */
97
+ function ensureStyles() {
98
+ if (stylesInjected) return;
99
+ if (typeof document === 'undefined') return;
100
+ if (document.getElementById(STYLE_ID)) {
101
+ stylesInjected = true;
102
+ return;
103
+ }
104
+ const styleEl = document.createElement('style');
105
+ styleEl.id = STYLE_ID;
106
+ styleEl.textContent = `
107
+ .adia-sr-only {
108
+ position: absolute !important;
109
+ width: 1px !important;
110
+ height: 1px !important;
111
+ padding: 0 !important;
112
+ margin: -1px !important;
113
+ overflow: hidden !important;
114
+ clip: rect(0, 0, 0, 0) !important;
115
+ white-space: nowrap !important;
116
+ border: 0 !important;
117
+ }
118
+ `;
119
+ document.head?.appendChild(styleEl);
120
+ stylesInjected = true;
121
+ }
122
+
123
+ /**
124
+ * Write a message into the chosen live region. `aria-atomic="true"`
125
+ * means AT re-reads the full new value on every change — but if the
126
+ * caller writes the *same* string back-to-back, some screen readers
127
+ * skip the re-announce. We blank the region first via a microtask
128
+ * gap, then write the message, to defeat that diffing.
129
+ */
130
+ export function announce(message, priority = 'polite') {
131
+ const region = getRegion(priority);
132
+ if (!region) return false;
133
+ // Blank then write. The microtask ensures AT sees a true content
134
+ // change even when the same message fires twice in a row.
135
+ region.textContent = '';
136
+ // queueMicrotask (or a microtask via Promise.resolve) keeps this
137
+ // synchronous-feeling for callers but gives the AT layer a chance
138
+ // to register the empty state before the new message lands.
139
+ queueMicrotask(() => {
140
+ region.textContent = String(message ?? '');
141
+ });
142
+ return true;
143
+ }
144
+
145
+ /**
146
+ * Test-only: tear down both regions + the style block so test cases
147
+ * don't leak state across each other. Not part of the public API.
148
+ */
149
+ export function _resetRegions() {
150
+ if (politeRegion && politeRegion.parentNode) politeRegion.remove();
151
+ if (assertiveRegion && assertiveRegion.parentNode) assertiveRegion.remove();
152
+ const styleEl = typeof document !== 'undefined' ? document.getElementById(STYLE_ID) : null;
153
+ if (styleEl) styleEl.remove();
154
+ politeRegion = null;
155
+ assertiveRegion = null;
156
+ stylesInjected = false;
157
+ }