@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.
- package/components/agent-trace/agent-trace.css +24 -3
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +90 -60
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +125 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.js +8 -3
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /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 './
|
|
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('
|
|
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
|
+
});
|