@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
@@ -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
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { announcer } from './announcer.js';
3
+ import { _resetRegions, getRegion } from './announcer-stage.js';
4
+ import { mountHost, connectTrait, spyEvent, resetDOM, tick, wait } from './test-helpers.js';
5
+
6
+ // happy-dom doesn't speak — we assert region textContent instead.
7
+ async function settleAnnounce() {
8
+ // The stage's blank-then-write uses queueMicrotask. Two ticks lets
9
+ // both the blank + the message land before we read.
10
+ await tick();
11
+ await tick();
12
+ }
13
+
14
+ describe('announcer', () => {
15
+ beforeEach(() => {
16
+ resetDOM();
17
+ _resetRegions();
18
+ });
19
+
20
+ it('schema: declares announcement-made event + four config attrs', () => {
21
+ expect(announcer.schema.name).toBe('announcer');
22
+ expect(announcer.schema.category).toBe('audio-haptics-sensory');
23
+ expect(announcer.schema.events).toContain('announcement-made');
24
+ expect(announcer.schema.config).toEqual([
25
+ 'data-announce-on',
26
+ 'data-announce-message',
27
+ 'data-announce-priority',
28
+ 'data-announce-throttle',
29
+ ]);
30
+ });
31
+
32
+ it('flips data-announcer-active on connect and cleans up on disconnect', () => {
33
+ const host = mountHost('div', { 'data-announce-on': 'click' });
34
+ const inst = connectTrait(announcer, host);
35
+ expect(host.hasAttribute('data-announcer-active')).toBe(true);
36
+ inst.disconnect(host);
37
+ expect(host.hasAttribute('data-announcer-active')).toBe(false);
38
+ });
39
+
40
+ it('with no data-announce-on: trait connects + disconnects without listening', () => {
41
+ const host = mountHost();
42
+ const inst = connectTrait(announcer, host);
43
+ expect(host.hasAttribute('data-announcer-active')).toBe(true);
44
+ // Firing some random event should not crash and not announce anything.
45
+ host.dispatchEvent(new CustomEvent('foo', { detail: 'bar' }));
46
+ expect(document.getElementById('adia-live-polite')).toBeNull();
47
+ inst.disconnect(host);
48
+ });
49
+
50
+ it('writes data-announce-message into the polite region by default', async () => {
51
+ const host = mountHost('div', {
52
+ 'data-announce-on': 'submit-success',
53
+ 'data-announce-message': 'Form submitted successfully.',
54
+ 'data-announce-throttle': '0',
55
+ });
56
+ connectTrait(announcer, host);
57
+ host.dispatchEvent(new CustomEvent('submit-success', { bubbles: true }));
58
+ await settleAnnounce();
59
+ const region = document.getElementById('adia-live-polite');
60
+ expect(region).not.toBeNull();
61
+ expect(region.getAttribute('aria-live')).toBe('polite');
62
+ expect(region.textContent).toBe('Form submitted successfully.');
63
+ // Assertive region should NOT exist when only polite messages have fired.
64
+ expect(document.getElementById('adia-live-assertive')).toBeNull();
65
+ });
66
+
67
+ it('routes data-announce-priority="assertive" to the assertive region', async () => {
68
+ const host = mountHost('div', {
69
+ 'data-announce-on': 'invalid',
70
+ 'data-announce-message': 'Email is required.',
71
+ 'data-announce-priority': 'assertive',
72
+ 'data-announce-throttle': '0',
73
+ });
74
+ connectTrait(announcer, host);
75
+ host.dispatchEvent(new CustomEvent('invalid', { bubbles: true }));
76
+ await settleAnnounce();
77
+ const assertiveRegion = document.getElementById('adia-live-assertive');
78
+ expect(assertiveRegion).not.toBeNull();
79
+ expect(assertiveRegion.getAttribute('aria-live')).toBe('assertive');
80
+ expect(assertiveRegion.getAttribute('role')).toBe('alert');
81
+ expect(assertiveRegion.textContent).toBe('Email is required.');
82
+ });
83
+
84
+ it('substitutes {detail} placeholder from a scalar event detail', async () => {
85
+ const host = mountHost('div', {
86
+ 'data-announce-on': 'count-up-done',
87
+ 'data-announce-message': 'Counted to {detail}.',
88
+ 'data-announce-throttle': '0',
89
+ });
90
+ connectTrait(announcer, host);
91
+ host.dispatchEvent(new CustomEvent('count-up-done', { detail: 18931, bubbles: true }));
92
+ await settleAnnounce();
93
+ const region = document.getElementById('adia-live-polite');
94
+ expect(region.textContent).toBe('Counted to 18931.');
95
+ });
96
+
97
+ it('substitutes {detail} placeholder from object detail.message', async () => {
98
+ const host = mountHost('div', {
99
+ 'data-announce-on': 'validated',
100
+ 'data-announce-message': 'Validation: {detail}.',
101
+ 'data-announce-throttle': '0',
102
+ });
103
+ connectTrait(announcer, host);
104
+ host.dispatchEvent(new CustomEvent('validated', {
105
+ detail: { valid: false, message: 'Invalid email address' },
106
+ bubbles: true,
107
+ }));
108
+ await settleAnnounce();
109
+ const region = document.getElementById('adia-live-polite');
110
+ expect(region.textContent).toBe('Validation: Invalid email address.');
111
+ });
112
+
113
+ it('falls back to event.detail.message when no template is set', async () => {
114
+ const host = mountHost('div', {
115
+ 'data-announce-on': 'validated',
116
+ 'data-announce-throttle': '0',
117
+ });
118
+ connectTrait(announcer, host);
119
+ host.dispatchEvent(new CustomEvent('validated', {
120
+ detail: { message: 'Required field is empty' },
121
+ bubbles: true,
122
+ }));
123
+ await settleAnnounce();
124
+ const region = document.getElementById('adia-live-polite');
125
+ expect(region.textContent).toBe('Required field is empty');
126
+ });
127
+
128
+ it('falls back to data-validation-message when no detail and no template', async () => {
129
+ const host = mountHost('div', {
130
+ 'data-announce-on': 'validated',
131
+ 'data-validation-message': 'This field is required',
132
+ 'data-announce-throttle': '0',
133
+ });
134
+ connectTrait(announcer, host);
135
+ host.dispatchEvent(new CustomEvent('validated', { bubbles: true }));
136
+ await settleAnnounce();
137
+ const region = document.getElementById('adia-live-polite');
138
+ expect(region.textContent).toBe('This field is required');
139
+ });
140
+
141
+ it('skips announce when there is no resolvable message', async () => {
142
+ const host = mountHost('div', {
143
+ 'data-announce-on': 'noop',
144
+ 'data-announce-throttle': '0',
145
+ });
146
+ connectTrait(announcer, host);
147
+ const spy = spyEvent(host, 'announcement-made');
148
+ host.dispatchEvent(new CustomEvent('noop', { bubbles: true }));
149
+ await settleAnnounce();
150
+ expect(spy.count).toBe(0);
151
+ // No region created either — nothing to write into.
152
+ const region = document.getElementById('adia-live-polite');
153
+ expect(region == null || region.textContent === '').toBe(true);
154
+ });
155
+
156
+ it('throttle: suppresses a second announce inside the window', async () => {
157
+ const host = mountHost('div', {
158
+ 'data-announce-on': 'tick',
159
+ 'data-announce-message': 'Tick at {detail}',
160
+ 'data-announce-throttle': '1000',
161
+ });
162
+ connectTrait(announcer, host);
163
+ const spy = spyEvent(host, 'announcement-made');
164
+ host.dispatchEvent(new CustomEvent('tick', { detail: 1, bubbles: true }));
165
+ host.dispatchEvent(new CustomEvent('tick', { detail: 2, bubbles: true }));
166
+ host.dispatchEvent(new CustomEvent('tick', { detail: 3, bubbles: true }));
167
+ await settleAnnounce();
168
+ expect(spy.count).toBe(1);
169
+ expect(spy.last.message).toBe('Tick at 1');
170
+ });
171
+
172
+ it('throttle=0 lets every event through', async () => {
173
+ const host = mountHost('div', {
174
+ 'data-announce-on': 'tick',
175
+ 'data-announce-message': 'Tick at {detail}',
176
+ 'data-announce-throttle': '0',
177
+ });
178
+ connectTrait(announcer, host);
179
+ const spy = spyEvent(host, 'announcement-made');
180
+ host.dispatchEvent(new CustomEvent('tick', { detail: 1 }));
181
+ host.dispatchEvent(new CustomEvent('tick', { detail: 2 }));
182
+ await settleAnnounce();
183
+ expect(spy.count).toBe(2);
184
+ });
185
+
186
+ it('dispatches announcement-made with { message, priority } detail', async () => {
187
+ const host = mountHost('div', {
188
+ 'data-announce-on': 'go',
189
+ 'data-announce-message': 'Hello',
190
+ 'data-announce-priority': 'assertive',
191
+ 'data-announce-throttle': '0',
192
+ });
193
+ connectTrait(announcer, host);
194
+ const spy = spyEvent(host, 'announcement-made');
195
+ host.dispatchEvent(new CustomEvent('go', { bubbles: true }));
196
+ await settleAnnounce();
197
+ expect(spy.count).toBe(1);
198
+ expect(spy.last.message).toBe('Hello');
199
+ expect(spy.last.priority).toBe('assertive');
200
+ });
201
+
202
+ it('singleton regions: two announcer instances share one polite region', async () => {
203
+ const a = mountHost('div', {
204
+ 'data-announce-on': 'go',
205
+ 'data-announce-message': 'A says hi',
206
+ 'data-announce-throttle': '0',
207
+ });
208
+ const b = mountHost('div', {
209
+ 'data-announce-on': 'go',
210
+ 'data-announce-message': 'B says hi',
211
+ 'data-announce-throttle': '0',
212
+ });
213
+ connectTrait(announcer, a);
214
+ connectTrait(announcer, b);
215
+ a.dispatchEvent(new CustomEvent('go'));
216
+ await settleAnnounce();
217
+ b.dispatchEvent(new CustomEvent('go'));
218
+ await settleAnnounce();
219
+ // Only one polite region in the document.
220
+ const regions = document.querySelectorAll('#adia-live-polite');
221
+ expect(regions.length).toBe(1);
222
+ expect(regions[0].textContent).toBe('B says hi');
223
+ });
224
+
225
+ it('regions are created with sr-only clipping (visually hidden, AT-visible)', async () => {
226
+ const host = mountHost('div', {
227
+ 'data-announce-on': 'go',
228
+ 'data-announce-message': 'hi',
229
+ 'data-announce-throttle': '0',
230
+ });
231
+ connectTrait(announcer, host);
232
+ host.dispatchEvent(new CustomEvent('go'));
233
+ await settleAnnounce();
234
+ const region = document.getElementById('adia-live-polite');
235
+ expect(region.classList.contains('adia-sr-only')).toBe(true);
236
+ expect(region.getAttribute('aria-atomic')).toBe('true');
237
+ // The role is 'status' for polite, 'alert' for assertive.
238
+ expect(region.getAttribute('role')).toBe('status');
239
+ });
240
+
241
+ it('disconnect leaves the singleton regions in place for other consumers', () => {
242
+ const host = mountHost('div', { 'data-announce-on': 'go' });
243
+ // Pre-create the region by hand so we know it's there before connect.
244
+ getRegion('polite');
245
+ const inst = connectTrait(announcer, host);
246
+ inst.disconnect(host);
247
+ expect(document.getElementById('adia-live-polite')).not.toBeNull();
248
+ });
249
+
250
+ it('reconnect after disconnect re-listens cleanly', async () => {
251
+ const host = mountHost('div', {
252
+ 'data-announce-on': 'go',
253
+ 'data-announce-message': 'reconnected',
254
+ 'data-announce-throttle': '0',
255
+ });
256
+ const inst1 = connectTrait(announcer, host);
257
+ inst1.disconnect(host);
258
+ host.dispatchEvent(new CustomEvent('go'));
259
+ await settleAnnounce();
260
+ expect(document.getElementById('adia-live-polite')?.textContent || '').toBe('');
261
+
262
+ const inst2 = connectTrait(announcer, host);
263
+ host.dispatchEvent(new CustomEvent('go'));
264
+ await settleAnnounce();
265
+ expect(document.getElementById('adia-live-polite').textContent).toBe('reconnected');
266
+ inst2.disconnect(host);
267
+ });
268
+ });
@@ -0,0 +1,234 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * 2D arrow-key navigation for grids — calendars, menubars, color pickers,
5
+ * APG `grid` pattern. Sibling 1D trait `keyboard-nav` only fires intent
6
+ * events; this trait owns row+col state and physically moves the active
7
+ * cell.
8
+ *
9
+ * <div role="grid" data-grid-columns="7">
10
+ * <button-ui>1</button-ui>
11
+ * <button-ui>2</button-ui>
12
+ * ...
13
+ * </div>
14
+ *
15
+ * arrowGridNav().connect(grid);
16
+ * grid.addEventListener('grid-activate', (e) => select(e.detail.row, e.detail.col));
17
+ *
18
+ * Two structural modes via `data-grid-mode`:
19
+ * "row-flat" (default) — children are grid items; row/col are inferred
20
+ * from index ÷ data-grid-columns.
21
+ * "row-nested" — children are row containers; each row's children are cells.
22
+ */
23
+ export const arrowGridNav = defineTrait({
24
+ name: 'arrow-grid-nav',
25
+ category: 'keyboard-navigation',
26
+ description: '2D arrow-key navigation for grids, calendars, menubars (APG grid pattern)',
27
+ attributes: ['data-arrow-grid-nav-active', 'data-grid-active-row', 'data-grid-active-col'],
28
+ events: ['grid-activate', 'grid-edge', 'grid-cell-change'],
29
+ config: ['data-grid-columns', 'data-grid-mode'],
30
+ setup({ host }) {
31
+ let activeRow = 0;
32
+ let activeCol = 0;
33
+ let activeCell = null;
34
+ let observer = null;
35
+
36
+ function getMode() {
37
+ return host.getAttribute('data-grid-mode') === 'row-nested' ? 'row-nested' : 'row-flat';
38
+ }
39
+
40
+ function getColumns() {
41
+ const raw = parseInt(host.getAttribute('data-grid-columns'), 10);
42
+ return Number.isFinite(raw) && raw > 0 ? raw : 1;
43
+ }
44
+
45
+ /**
46
+ * Returns a 2D matrix of cells: cells[row][col].
47
+ * Both modes return the same shape so navigation logic is uniform.
48
+ */
49
+ function discoverCells() {
50
+ const mode = getMode();
51
+ if (mode === 'row-nested') {
52
+ // Each direct child is a row container; its children are cells.
53
+ return [...host.children].map((rowEl) =>
54
+ [...rowEl.children].filter((c) => !c.hasAttribute('disabled')),
55
+ );
56
+ }
57
+ // row-flat: split a flat child list into rows by column count.
58
+ const cols = getColumns();
59
+ const flat = [...host.children].filter((c) => !c.hasAttribute('disabled'));
60
+ const rows = [];
61
+ for (let i = 0; i < flat.length; i += cols) {
62
+ rows.push(flat.slice(i, i + cols));
63
+ }
64
+ return rows;
65
+ }
66
+
67
+ function clearActiveCell() {
68
+ if (activeCell) {
69
+ activeCell.removeAttribute('data-grid-active');
70
+ activeCell.setAttribute('tabindex', '-1');
71
+ activeCell = null;
72
+ }
73
+ }
74
+
75
+ function paint(cells, prevRow, prevCol) {
76
+ const safeRow = Math.max(0, Math.min(activeRow, cells.length - 1));
77
+ const row = cells[safeRow] || [];
78
+ const safeCol = Math.max(0, Math.min(activeCol, row.length - 1));
79
+ activeRow = safeRow;
80
+ activeCol = safeCol;
81
+
82
+ clearActiveCell();
83
+
84
+ const cell = row[safeCol];
85
+ if (cell) {
86
+ activeCell = cell;
87
+ cell.setAttribute('data-grid-active', '');
88
+ cell.setAttribute('tabindex', '0');
89
+ }
90
+
91
+ host.setAttribute('data-grid-active-row', String(activeRow));
92
+ host.setAttribute('data-grid-active-col', String(activeCol));
93
+
94
+ const changed = prevRow !== activeRow || prevCol !== activeCol;
95
+ if (changed) {
96
+ host.dispatchEvent(new CustomEvent('grid-cell-change', {
97
+ bubbles: true,
98
+ detail: { row: activeRow, col: activeCol, element: cell || null },
99
+ }));
100
+ }
101
+ }
102
+
103
+ function paintInitial() {
104
+ const cells = discoverCells();
105
+ // Mark every cell as roving-tabindex -1 so Tab lands once on the host.
106
+ for (const row of cells) {
107
+ for (const cell of row) {
108
+ if (!cell.hasAttribute('tabindex')) cell.setAttribute('tabindex', '-1');
109
+ }
110
+ }
111
+ paint(cells, -1, -1);
112
+ }
113
+
114
+ function emitEdge(edge) {
115
+ host.dispatchEvent(new CustomEvent('grid-edge', {
116
+ bubbles: true,
117
+ detail: { edge, row: activeRow, col: activeCol },
118
+ }));
119
+ }
120
+
121
+ function onKeyDown(e) {
122
+ // Skip if the event is from a typing surface inside a cell.
123
+ const tag = (e.target?.tagName || '').toLowerCase();
124
+ if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
125
+
126
+ const cells = discoverCells();
127
+ if (cells.length === 0) return;
128
+
129
+ const prevRow = activeRow;
130
+ const prevCol = activeCol;
131
+ const lastRow = cells.length - 1;
132
+ const currentRow = cells[activeRow] || [];
133
+ const lastCol = currentRow.length - 1;
134
+
135
+ let handled = true;
136
+
137
+ switch (e.key) {
138
+ case 'ArrowRight':
139
+ if (activeCol < lastCol) activeCol++;
140
+ else { emitEdge('right'); handled = true; }
141
+ break;
142
+ case 'ArrowLeft':
143
+ if (activeCol > 0) activeCol--;
144
+ else { emitEdge('left'); handled = true; }
145
+ break;
146
+ case 'ArrowDown': {
147
+ if (activeRow < lastRow) {
148
+ activeRow++;
149
+ const newRow = cells[activeRow] || [];
150
+ // Clamp col to new row's length (rows can vary in row-nested mode).
151
+ if (activeCol > newRow.length - 1) activeCol = Math.max(0, newRow.length - 1);
152
+ } else {
153
+ emitEdge('bottom');
154
+ }
155
+ break;
156
+ }
157
+ case 'ArrowUp': {
158
+ if (activeRow > 0) {
159
+ activeRow--;
160
+ const newRow = cells[activeRow] || [];
161
+ if (activeCol > newRow.length - 1) activeCol = Math.max(0, newRow.length - 1);
162
+ } else {
163
+ emitEdge('top');
164
+ }
165
+ break;
166
+ }
167
+ case 'Home':
168
+ if (e.ctrlKey || e.metaKey) {
169
+ activeRow = 0;
170
+ activeCol = 0;
171
+ } else {
172
+ activeCol = 0;
173
+ }
174
+ break;
175
+ case 'End':
176
+ if (e.ctrlKey || e.metaKey) {
177
+ activeRow = lastRow;
178
+ const lastRowCells = cells[lastRow] || [];
179
+ activeCol = Math.max(0, lastRowCells.length - 1);
180
+ } else {
181
+ activeCol = lastCol;
182
+ }
183
+ break;
184
+ case 'Enter':
185
+ case ' ': {
186
+ // Activate without moving; paint not needed.
187
+ const cell = currentRow[activeCol];
188
+ host.dispatchEvent(new CustomEvent('grid-activate', {
189
+ bubbles: true,
190
+ detail: { row: activeRow, col: activeCol, element: cell || null },
191
+ }));
192
+ e.preventDefault();
193
+ return;
194
+ }
195
+ default:
196
+ handled = false;
197
+ }
198
+
199
+ if (!handled) return;
200
+
201
+ e.preventDefault();
202
+ paint(cells, prevRow, prevCol);
203
+ // Move physical focus to the new cell so screen readers + visible focus follow.
204
+ activeCell?.focus({ preventScroll: false });
205
+ }
206
+
207
+ function onMutate() {
208
+ // Children added / removed — re-paint with clamped indices.
209
+ const cells = discoverCells();
210
+ if (cells.length === 0) return;
211
+ paint(cells, -1, -1);
212
+ }
213
+
214
+ host.setAttribute('data-arrow-grid-nav-active', '');
215
+ if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0');
216
+ host.addEventListener('keydown', onKeyDown);
217
+
218
+ paintInitial();
219
+
220
+ if (typeof MutationObserver !== 'undefined') {
221
+ observer = new MutationObserver(onMutate);
222
+ observer.observe(host, { childList: true, subtree: getMode() === 'row-nested' });
223
+ }
224
+
225
+ return () => {
226
+ host.removeEventListener('keydown', onKeyDown);
227
+ if (observer) { observer.disconnect(); observer = null; }
228
+ clearActiveCell();
229
+ host.removeAttribute('data-arrow-grid-nav-active');
230
+ host.removeAttribute('data-grid-active-row');
231
+ host.removeAttribute('data-grid-active-col');
232
+ };
233
+ },
234
+ });