@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.
- 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 +21 -0
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +12 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +257 -4
- 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 +67 -63
- 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 +43 -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.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,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: [
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 './
|
|
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
|
+
}
|