@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,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
+ }
@@ -0,0 +1,145 @@
1
+ import { defineTrait } from './define.js';
2
+ import { announce, getRegion } from './announcer-stage.js';
3
+
4
+ /**
5
+ * `announcer` — aria-live mirror for AT (assistive tech) listeners.
6
+ *
7
+ * Every audio/haptic/visual trait above WCAG 4.1.3 (Status Messages)
8
+ * leaves AT users behind: count-up tickers, validation flips, typewriter
9
+ * reveals — none of these announce themselves. `announcer` is the missing
10
+ * pair. It hooks any host event into one of two body-level singleton
11
+ * `aria-live` regions; AT speaks the message, sighted users see nothing.
12
+ *
13
+ * Triggers
14
+ * --------
15
+ * data-announce-on the event name to listen for on the host
16
+ * data-announce-message the message to write. Supports `{detail}`
17
+ * which substitutes `event.detail` — useful for
18
+ * validation errors / count-up final values.
19
+ * If `event.detail` is an object, the
20
+ * `message` field is preferred when present.
21
+ * data-announce-priority 'polite' (default) | 'assertive'
22
+ * data-announce-throttle minimum ms between two announcements from
23
+ * the same trait instance (default 1000).
24
+ * Burst-protect against rapid-fire updates.
25
+ *
26
+ * Singleton regions
27
+ * -----------------
28
+ * Two body-level regions are created lazily on first announce — one
29
+ * polite, one assertive. Both are visually clipped via `.adia-sr-only`
30
+ * so sighted users never see them, but stay in the accessibility tree
31
+ * so screen readers announce the contents. Multiple announcer traits
32
+ * share the same two regions; cleanup leaves them in place.
33
+ *
34
+ * Why a microtask blank-then-write
35
+ * --------------------------------
36
+ * `aria-atomic="true"` re-reads the full content on every change, but
37
+ * many screen readers skip a write if the new value equals the previous
38
+ * one. The stage helper blanks the region in a microtask, then writes
39
+ * the message — defeats the diff and forces a re-announce.
40
+ */
41
+ export const announcer = defineTrait({
42
+ name: 'announcer',
43
+ category: 'audio-haptics-sensory',
44
+ description: 'aria-live mirror for AT — announces host state changes via singleton polite/assertive regions',
45
+ attributes: ['data-announcer-active'],
46
+ events: ['announcement-made'],
47
+ config: [
48
+ 'data-announce-on',
49
+ 'data-announce-message',
50
+ 'data-announce-priority',
51
+ 'data-announce-throttle',
52
+ ],
53
+ setup({ host }) {
54
+ const eventName = host.getAttribute('data-announce-on');
55
+ if (!eventName) {
56
+ // No trigger configured — quietly no-op. The trait still flips its
57
+ // active flag so authors can debug "is the trait wired?" via DevTools.
58
+ host.setAttribute('data-announcer-active', '');
59
+ return () => {
60
+ host.removeAttribute('data-announcer-active');
61
+ };
62
+ }
63
+
64
+ const throttleMs = parseInt(host.getAttribute('data-announce-throttle'), 10);
65
+ const throttle = Number.isFinite(throttleMs) && throttleMs >= 0 ? throttleMs : 1000;
66
+
67
+ // `null` so the first announce always wins regardless of throttle —
68
+ // the throttle is "minimum gap between two announcements," not a
69
+ // delay before the first one. Subsequent events compare against
70
+ // the previous timestamp.
71
+ let lastAnnouncedAt = null;
72
+
73
+ function readPriority() {
74
+ const raw = host.getAttribute('data-announce-priority');
75
+ return raw === 'assertive' ? 'assertive' : 'polite';
76
+ }
77
+
78
+ function resolveMessage(event) {
79
+ // Priority for message resolution:
80
+ // 1. event.detail.message (object detail with explicit field)
81
+ // 2. event.detail (scalar / serializable)
82
+ // 3. data-announce-message (with `{detail}` placeholder)
83
+ // 4. host.getAttribute('data-validation-message') as last-ditch
84
+ // fallback — pairs cleanly with the validation trait.
85
+ const tmpl = host.getAttribute('data-announce-message');
86
+ const detail = event?.detail;
87
+
88
+ if (tmpl) {
89
+ const detailStr = detail == null
90
+ ? ''
91
+ : (typeof detail === 'object'
92
+ ? (detail.message ?? JSON.stringify(detail))
93
+ : String(detail));
94
+ return tmpl.includes('{detail}') ? tmpl.replace(/\{detail\}/g, detailStr) : tmpl;
95
+ }
96
+
97
+ if (detail != null) {
98
+ if (typeof detail === 'object') {
99
+ if (typeof detail.message === 'string') return detail.message;
100
+ // Fall through — opaque object detail without a message field
101
+ // isn't useful to AT. Return null to skip.
102
+ return null;
103
+ }
104
+ return String(detail);
105
+ }
106
+
107
+ const fallback = host.getAttribute('data-validation-message');
108
+ return fallback || null;
109
+ }
110
+
111
+ function onTrigger(event) {
112
+ const message = resolveMessage(event);
113
+ if (!message) return;
114
+
115
+ const now = (typeof performance !== 'undefined' && performance.now)
116
+ ? performance.now()
117
+ : Date.now();
118
+ if (throttle > 0 && lastAnnouncedAt != null && now - lastAnnouncedAt < throttle) return;
119
+ lastAnnouncedAt = now;
120
+
121
+ const priority = readPriority();
122
+ const ok = announce(message, priority);
123
+ if (!ok) return;
124
+
125
+ host.dispatchEvent(new CustomEvent('announcement-made', {
126
+ bubbles: true,
127
+ detail: { message, priority },
128
+ }));
129
+ }
130
+
131
+ host.addEventListener(eventName, onTrigger);
132
+ host.setAttribute('data-announcer-active', '');
133
+
134
+ // Pre-warm both regions so the first real announce doesn't pay
135
+ // the create-+-microtask-gap cost serially. Cheap (two empty divs)
136
+ // and idempotent if any other announcer instance already did it.
137
+ getRegion('polite');
138
+ if (readPriority() === 'assertive') getRegion('assertive');
139
+
140
+ return () => {
141
+ host.removeEventListener(eventName, onTrigger);
142
+ host.removeAttribute('data-announcer-active');
143
+ };
144
+ },
145
+ });