@jsenv/dom 0.11.2 → 0.12.0
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/dist/jsenv_dom.js +1020 -132
- package/package.json +1 -1
package/dist/jsenv_dom.js
CHANGED
|
@@ -110,17 +110,55 @@ const getElementSignature = (element) => {
|
|
|
110
110
|
if (dataUIName) {
|
|
111
111
|
return `${tagName}[data-ui-name="${dataUIName}"]`;
|
|
112
112
|
}
|
|
113
|
+
if (tagName === "input") {
|
|
114
|
+
const type = element.type || "text";
|
|
115
|
+
const name = element.getAttribute("name");
|
|
116
|
+
if (type === "radio" || type === "checkbox") {
|
|
117
|
+
const value = element.getAttribute("value");
|
|
118
|
+
if (name && value) {
|
|
119
|
+
return `${type}[name="${name}"][value="${value}"]`;
|
|
120
|
+
}
|
|
121
|
+
if (name) {
|
|
122
|
+
return `${type}[name="${name}"]`;
|
|
123
|
+
}
|
|
124
|
+
return `${type}`;
|
|
125
|
+
}
|
|
126
|
+
if (name) {
|
|
127
|
+
return `input[name="${name}"]`;
|
|
128
|
+
}
|
|
129
|
+
return `input[type="${type}"]`;
|
|
130
|
+
}
|
|
131
|
+
if (tagName === "form") {
|
|
132
|
+
const name = element.getAttribute("name");
|
|
133
|
+
if (name) {
|
|
134
|
+
return `form[name="${name}"]`;
|
|
135
|
+
}
|
|
136
|
+
return "form";
|
|
137
|
+
}
|
|
113
138
|
if (element === document.body) {
|
|
114
|
-
return "
|
|
139
|
+
return "document.body";
|
|
115
140
|
}
|
|
116
141
|
if (element === document.documentElement) {
|
|
117
|
-
return "
|
|
142
|
+
return "document.html";
|
|
118
143
|
}
|
|
119
144
|
const elementId = element.id;
|
|
120
|
-
const className = element.className;
|
|
121
145
|
if (elementId && !looksLikeGeneratedId(elementId)) {
|
|
122
146
|
return `${tagName}#${elementId}`;
|
|
123
147
|
}
|
|
148
|
+
if (tagName === "button") {
|
|
149
|
+
const text = element.textContent.trim();
|
|
150
|
+
if (text) {
|
|
151
|
+
const excerpt = text.length > 10 ? `${text.slice(0, 10)}…` : text;
|
|
152
|
+
return `button:text("${excerpt}")`;
|
|
153
|
+
}
|
|
154
|
+
const parentSignature = getElementSignature(element.parentElement);
|
|
155
|
+
return `${parentSignature} > button:empty`;
|
|
156
|
+
}
|
|
157
|
+
const role = element.getAttribute("role");
|
|
158
|
+
if (role) {
|
|
159
|
+
return `${tagName}[role="${role}"]`;
|
|
160
|
+
}
|
|
161
|
+
const className = element.className;
|
|
124
162
|
if (className) {
|
|
125
163
|
return `${tagName}.${className.split(" ").join(".")}`;
|
|
126
164
|
}
|
|
@@ -159,6 +197,7 @@ const looksLikeGeneratedId = (id) => {
|
|
|
159
197
|
* with `request_` by convention.
|
|
160
198
|
*/
|
|
161
199
|
|
|
200
|
+
|
|
162
201
|
/**
|
|
163
202
|
* Dispatches an internal event on `el`.
|
|
164
203
|
* Does not bubble — stays within the local subtree.
|
|
@@ -169,7 +208,8 @@ const dispatchInternalCustomEvent = (
|
|
|
169
208
|
customEventDetail,
|
|
170
209
|
) => {
|
|
171
210
|
const customEvent = new CustomEvent(customEventName, {
|
|
172
|
-
detail: customEventDetail,
|
|
211
|
+
detail: resolveEventDetail(customEventDetail),
|
|
212
|
+
cancelable: true,
|
|
173
213
|
});
|
|
174
214
|
return el.dispatchEvent(customEvent);
|
|
175
215
|
};
|
|
@@ -207,13 +247,156 @@ const dispatchCustomEvent = (el, customEventName, customEventDetail) => {
|
|
|
207
247
|
|
|
208
248
|
const resolveEventDetail = (customEventDetail) => {
|
|
209
249
|
const { event, ...rest } = customEventDetail ?? {};
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
} else if (event !== undefined) {
|
|
214
|
-
resolvedEvent = event;
|
|
250
|
+
const isWrappedCustomEvent = event?.detail?.event !== undefined;
|
|
251
|
+
if (!isWrappedCustomEvent) {
|
|
252
|
+
return { ...rest, event };
|
|
215
253
|
}
|
|
216
|
-
|
|
254
|
+
// Keep `event` as the direct parent so callers see the immediate facade.
|
|
255
|
+
// Build eventChain as [root, ...grandparents] — oldest first, excluding `event`.
|
|
256
|
+
const previousChain = event.detail.eventChain;
|
|
257
|
+
const eventChain = previousChain
|
|
258
|
+
? [...previousChain, event.detail.event]
|
|
259
|
+
: [event.detail.event];
|
|
260
|
+
return { ...rest, event, eventChain };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Returns true if the event itself or any event in its chain matches the predicate.
|
|
265
|
+
*
|
|
266
|
+
* The full chain checked (oldest to newest) is:
|
|
267
|
+
* initiator (event.detail.event) → ...intermediates (event.detail.eventChain)... → event
|
|
268
|
+
*
|
|
269
|
+
* Examples:
|
|
270
|
+
* findEvent(e, "mousedown")
|
|
271
|
+
* findEvent(e, ["mousedown", "touchstart"])
|
|
272
|
+
* findEvent(e, (e) => e.type === "mousedown")
|
|
273
|
+
* findEvent(e, (e) => e.type === "navi_list_select")
|
|
274
|
+
*/
|
|
275
|
+
const findEvent = (event, predicate) => {
|
|
276
|
+
if (!event) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const match = resolveEventPredicate(predicate);
|
|
280
|
+
if (match(event)) {
|
|
281
|
+
return event;
|
|
282
|
+
}
|
|
283
|
+
if (event.detail?.eventChain) {
|
|
284
|
+
for (const chainedEvent of event.detail.eventChain) {
|
|
285
|
+
if (match(chainedEvent)) {
|
|
286
|
+
return chainedEvent;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const initiator = event.detail?.event;
|
|
291
|
+
if (initiator) {
|
|
292
|
+
if (match(initiator)) {
|
|
293
|
+
return initiator;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const resolveEventPredicate = (predicate) => {
|
|
300
|
+
if (typeof predicate === "string") {
|
|
301
|
+
return (e) => e.type === predicate;
|
|
302
|
+
}
|
|
303
|
+
if (Array.isArray(predicate)) {
|
|
304
|
+
return (e) => predicate.includes(e.type);
|
|
305
|
+
}
|
|
306
|
+
return predicate;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Formats an event (and its chain when it's a custom event) for debug logging.
|
|
311
|
+
* For a plain browser event: `"mousedown" on button#submit`
|
|
312
|
+
* For a custom event with a chain: `"mousedown" on li#item-1 -> navi_list_request_select -> navi_list_nav`
|
|
313
|
+
*/
|
|
314
|
+
const formatEventSideEffect = (e, sideEffect) => {
|
|
315
|
+
const parts = [];
|
|
316
|
+
if (e.detail?.event !== undefined) {
|
|
317
|
+
const chain = e.detail.eventChain;
|
|
318
|
+
const initiator = chain ? chain[0] : e.detail.event;
|
|
319
|
+
parts.push(
|
|
320
|
+
`"${initiator.type}" on ${getElementSignature(initiator.target)}`,
|
|
321
|
+
);
|
|
322
|
+
if (chain) {
|
|
323
|
+
for (const chainedEvent of chain.slice(1)) {
|
|
324
|
+
parts.push(chainedEvent.type);
|
|
325
|
+
}
|
|
326
|
+
parts.push(e.detail.event.type);
|
|
327
|
+
}
|
|
328
|
+
parts.push(e.type);
|
|
329
|
+
} else {
|
|
330
|
+
parts.push(`"${e.type}" on ${getElementSignature(e.target)}`);
|
|
331
|
+
}
|
|
332
|
+
return `${parts.join(" -> ")} -> ${sideEffect}`;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Creates a stateful debug logger that groups side effects by their native initiator event.
|
|
337
|
+
*
|
|
338
|
+
* Usage:
|
|
339
|
+
* const log = createEventGroupLogger();
|
|
340
|
+
* log(e, "navi_action_requested"); // opens/reuses a group for the initiator event
|
|
341
|
+
* log("plain message"); // logs inside the current group (or standalone)
|
|
342
|
+
*
|
|
343
|
+
* The group closes automatically after the current JS task completes (setTimeout 0).
|
|
344
|
+
*/
|
|
345
|
+
const createEventGroupLogger = () => {
|
|
346
|
+
let currentInitiator = null;
|
|
347
|
+
let closeGroupTimeout = null;
|
|
348
|
+
|
|
349
|
+
const scheduleGroupEnd = () => {
|
|
350
|
+
if (closeGroupTimeout !== null) {
|
|
351
|
+
clearTimeout(closeGroupTimeout);
|
|
352
|
+
}
|
|
353
|
+
closeGroupTimeout = setTimeout(() => {
|
|
354
|
+
console.groupEnd();
|
|
355
|
+
currentInitiator = null;
|
|
356
|
+
closeGroupTimeout = null;
|
|
357
|
+
}, 0);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return (eOrMessage, sideEffect) => {
|
|
361
|
+
if (!(eOrMessage instanceof Event)) {
|
|
362
|
+
console.debug(eOrMessage);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const e = eOrMessage;
|
|
366
|
+
const chain = e.detail?.eventChain;
|
|
367
|
+
const initiator = chain ? chain[0] : (e.detail?.event ?? e);
|
|
368
|
+
if (initiator !== currentInitiator) {
|
|
369
|
+
if (currentInitiator !== null) {
|
|
370
|
+
clearTimeout(closeGroupTimeout);
|
|
371
|
+
closeGroupTimeout = null;
|
|
372
|
+
console.groupEnd();
|
|
373
|
+
}
|
|
374
|
+
const label = initiator.target
|
|
375
|
+
? `"${initiator.type}" on ${getElementSignature(initiator.target)}`
|
|
376
|
+
: `"${initiator.type}"`;
|
|
377
|
+
console.group(label);
|
|
378
|
+
currentInitiator = initiator;
|
|
379
|
+
}
|
|
380
|
+
const line = formatSideEffectLine(e, sideEffect);
|
|
381
|
+
console.debug(line);
|
|
382
|
+
scheduleGroupEnd();
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const formatSideEffectLine = (e, sideEffect) => {
|
|
387
|
+
const parts = [];
|
|
388
|
+
const chain = e.detail?.eventChain;
|
|
389
|
+
if (chain) {
|
|
390
|
+
// chain[0] is the root event, already shown as the group label — skip it
|
|
391
|
+
for (const chainedEvent of chain.slice(1)) {
|
|
392
|
+
parts.push(chainedEvent.type);
|
|
393
|
+
}
|
|
394
|
+
if (e.detail?.event) {
|
|
395
|
+
parts.push(e.detail.event.type);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
parts.push(sideEffect);
|
|
399
|
+
return parts.join(" -> ");
|
|
217
400
|
};
|
|
218
401
|
|
|
219
402
|
const createIterableWeakSet = () => {
|
|
@@ -3953,10 +4136,20 @@ const addActiveElementEffect = (callback) => {
|
|
|
3953
4136
|
return remove;
|
|
3954
4137
|
};
|
|
3955
4138
|
|
|
3956
|
-
|
|
3957
|
-
|
|
4139
|
+
/**
|
|
4140
|
+
* Returns whether a node is visible from a focus/keyboard-navigation perspective.
|
|
4141
|
+
* This intentionally ignores purely visual properties (opacity, clip, off-screen)
|
|
4142
|
+
* and only checks structural visibility: hidden attribute, display:none, visibility:hidden,
|
|
4143
|
+
* closed <details>/<dialog>/popover ancestors, and optionally aria-hidden ancestry.
|
|
4144
|
+
*
|
|
4145
|
+
* @param {Node} node
|
|
4146
|
+
* @param {{ excludeAriaHidden?: boolean }} [options]
|
|
4147
|
+
* @returns {boolean}
|
|
4148
|
+
*/
|
|
4149
|
+
const elementIsVisibleForFocus = (node, { excludeAriaHidden } = {}) => {
|
|
4150
|
+
return getFocusVisibilityInfo(node, { excludeAriaHidden }).visible;
|
|
3958
4151
|
};
|
|
3959
|
-
const getFocusVisibilityInfo = (node) => {
|
|
4152
|
+
const getFocusVisibilityInfo = (node, { excludeAriaHidden } = {}) => {
|
|
3960
4153
|
if (isDocumentElement(node)) {
|
|
3961
4154
|
return { visible: true, reason: "is document" };
|
|
3962
4155
|
}
|
|
@@ -3974,6 +4167,12 @@ const getFocusVisibilityInfo = (node) => {
|
|
|
3974
4167
|
if (isDocumentElement(nodeOrAncestor)) {
|
|
3975
4168
|
break;
|
|
3976
4169
|
}
|
|
4170
|
+
if (
|
|
4171
|
+
excludeAriaHidden &&
|
|
4172
|
+
nodeOrAncestor.getAttribute("aria-hidden") === "true"
|
|
4173
|
+
) {
|
|
4174
|
+
return { visible: false, reason: "inside aria-hidden element" };
|
|
4175
|
+
}
|
|
3977
4176
|
if (getStyle(nodeOrAncestor, "display") === "none") {
|
|
3978
4177
|
return { visible: false, reason: "ancestor uses display: none" };
|
|
3979
4178
|
}
|
|
@@ -4030,9 +4229,17 @@ const getVisuallyVisibleInfo = (
|
|
|
4030
4229
|
return { visible: false, reason: "clipped with clip property" };
|
|
4031
4230
|
}
|
|
4032
4231
|
|
|
4232
|
+
if (node.hasAttribute("navi-visually-hidden")) {
|
|
4233
|
+
return { visible: false, reason: "has navi-visually-hidden attribute" };
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4033
4236
|
const clipPathStyle = getStyle(node, "clip-path");
|
|
4034
|
-
if (clipPathStyle
|
|
4035
|
-
|
|
4237
|
+
if (clipPathStyle) {
|
|
4238
|
+
// inset(N%) where N >= 50 collapses the visible area to nothing
|
|
4239
|
+
const insetMatch = clipPathStyle.match(/^inset\((\d+)%/);
|
|
4240
|
+
if (insetMatch && Number(insetMatch[1]) >= 50) {
|
|
4241
|
+
return { visible: false, reason: "clipped with clip-path" };
|
|
4242
|
+
}
|
|
4036
4243
|
}
|
|
4037
4244
|
|
|
4038
4245
|
// Check if positioned off-screen (unless option says to count as visible)
|
|
@@ -4049,7 +4256,7 @@ const getVisuallyVisibleInfo = (
|
|
|
4049
4256
|
|
|
4050
4257
|
// Check for transform scale(0)
|
|
4051
4258
|
const transformStyle = getStyle(node, "transform");
|
|
4052
|
-
if (transformStyle
|
|
4259
|
+
if (transformStyle.scale === 0) {
|
|
4053
4260
|
return { visible: false, reason: "scaled to zero with transform" };
|
|
4054
4261
|
}
|
|
4055
4262
|
|
|
@@ -4068,47 +4275,64 @@ const getFirstVisuallyVisibleAncestor = (node, options = {}) => {
|
|
|
4068
4275
|
return null;
|
|
4069
4276
|
};
|
|
4070
4277
|
|
|
4071
|
-
|
|
4278
|
+
/**
|
|
4279
|
+
* Returns whether a node can receive focus, combining structural visibility
|
|
4280
|
+
* (via {@link elementIsVisibleForFocus}) with interaction capability checks
|
|
4281
|
+
* (disabled, inert) and element-type-specific focusability rules.
|
|
4282
|
+
*
|
|
4283
|
+
* @param {Node} node
|
|
4284
|
+
* @param {{ excludeAriaHidden?: boolean }} [options]
|
|
4285
|
+
* - `excludeAriaHidden`: when true, elements inside an `aria-hidden="true"`
|
|
4286
|
+
* subtree are considered non-focusable (matching screen reader behaviour).
|
|
4287
|
+
* @returns {boolean}
|
|
4288
|
+
*/
|
|
4289
|
+
const elementIsFocusable = (node, { excludeAriaHidden } = {}) => {
|
|
4072
4290
|
// only element node can be focused, document, textNodes etc cannot
|
|
4073
4291
|
if (node.nodeType !== 1) {
|
|
4074
4292
|
return false;
|
|
4075
4293
|
}
|
|
4294
|
+
if (node.hasAttribute("navi-focus-delegate")) {
|
|
4295
|
+
return false;
|
|
4296
|
+
}
|
|
4076
4297
|
if (!canInteract(node)) {
|
|
4077
4298
|
return false;
|
|
4078
4299
|
}
|
|
4300
|
+
const canFocus = (node) =>
|
|
4301
|
+
elementIsVisibleForFocus(node, { excludeAriaHidden });
|
|
4302
|
+
|
|
4079
4303
|
const nodeName = node.nodeName.toLowerCase();
|
|
4080
4304
|
if (nodeName === "input") {
|
|
4081
4305
|
if (node.type === "hidden") {
|
|
4082
4306
|
return false;
|
|
4083
4307
|
}
|
|
4084
|
-
return
|
|
4308
|
+
return canFocus(node);
|
|
4085
4309
|
}
|
|
4086
4310
|
if (
|
|
4087
4311
|
["button", "select", "datalist", "iframe", "textarea"].indexOf(nodeName) >
|
|
4088
4312
|
-1
|
|
4089
4313
|
) {
|
|
4090
|
-
return
|
|
4314
|
+
return canFocus(node);
|
|
4091
4315
|
}
|
|
4092
4316
|
if (["a", "area"].indexOf(nodeName) > -1) {
|
|
4093
4317
|
if (node.hasAttribute("href") === false) {
|
|
4094
4318
|
return false;
|
|
4095
4319
|
}
|
|
4096
|
-
return
|
|
4320
|
+
return canFocus(node);
|
|
4097
4321
|
}
|
|
4098
4322
|
if (["audio", "video"].indexOf(nodeName) > -1) {
|
|
4099
4323
|
if (node.hasAttribute("controls") === false) {
|
|
4100
4324
|
return false;
|
|
4101
4325
|
}
|
|
4102
|
-
return
|
|
4326
|
+
return canFocus(node);
|
|
4103
4327
|
}
|
|
4104
4328
|
if (nodeName === "summary") {
|
|
4105
|
-
return
|
|
4329
|
+
return canFocus(node);
|
|
4106
4330
|
}
|
|
4107
4331
|
if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
|
|
4108
|
-
return
|
|
4332
|
+
return canFocus(node);
|
|
4109
4333
|
}
|
|
4110
4334
|
if (node.hasAttribute("draggable")) {
|
|
4111
|
-
return
|
|
4335
|
+
return canFocus(node);
|
|
4112
4336
|
}
|
|
4113
4337
|
return false;
|
|
4114
4338
|
};
|
|
@@ -4124,6 +4348,33 @@ const canInteract = (element) => {
|
|
|
4124
4348
|
return true;
|
|
4125
4349
|
};
|
|
4126
4350
|
|
|
4351
|
+
/**
|
|
4352
|
+
* Given an element with the `navi-focus-delegate` attribute, returns the first
|
|
4353
|
+
* focusable ancestor that should receive focus instead.
|
|
4354
|
+
*
|
|
4355
|
+
* Elements marked with `navi-focus-delegate` opt out of being focusable
|
|
4356
|
+
* themselves (see {@link elementIsFocusable}) and redirect focus upward to
|
|
4357
|
+
* their nearest focusable ancestor.
|
|
4358
|
+
*
|
|
4359
|
+
* Returns `null` when the attribute is absent or no focusable ancestor exists.
|
|
4360
|
+
*
|
|
4361
|
+
* @param {Element} el
|
|
4362
|
+
* @returns {Element|null}
|
|
4363
|
+
*/
|
|
4364
|
+
const findFocusDelegateTarget = (el) => {
|
|
4365
|
+
if (!el.hasAttribute("navi-focus-delegate")) {
|
|
4366
|
+
return null;
|
|
4367
|
+
}
|
|
4368
|
+
let ancestor = el.parentElement;
|
|
4369
|
+
while (ancestor) {
|
|
4370
|
+
if (elementIsFocusable(ancestor)) {
|
|
4371
|
+
return ancestor;
|
|
4372
|
+
}
|
|
4373
|
+
ancestor = ancestor.parentElement;
|
|
4374
|
+
}
|
|
4375
|
+
return null;
|
|
4376
|
+
};
|
|
4377
|
+
|
|
4127
4378
|
const findFocusable = (element) => {
|
|
4128
4379
|
const associatedElements = getAssociatedElements(element);
|
|
4129
4380
|
if (associatedElements) {
|
|
@@ -4139,58 +4390,265 @@ const findFocusable = (element) => {
|
|
|
4139
4390
|
return element;
|
|
4140
4391
|
}
|
|
4141
4392
|
const focusableDescendant = findDescendant(element, elementIsFocusable);
|
|
4393
|
+
if (focusableDescendant) {
|
|
4394
|
+
// If the first focusable is an unchecked radio/checkbox, prefer the checked
|
|
4395
|
+
// sibling in the same group (mirrors native browser radio focus behavior
|
|
4396
|
+
// and gives focus to the selected item in a selectable list).
|
|
4397
|
+
const { tagName, type, name } = focusableDescendant;
|
|
4398
|
+
if (
|
|
4399
|
+
tagName === "INPUT" &&
|
|
4400
|
+
(type === "radio" || type === "checkbox") &&
|
|
4401
|
+
!focusableDescendant.checked &&
|
|
4402
|
+
name
|
|
4403
|
+
) {
|
|
4404
|
+
const groupContainer = focusableDescendant.form || document;
|
|
4405
|
+
const checkedInput = groupContainer.querySelector(
|
|
4406
|
+
`input[type="${type}"][name="${CSS.escape(name)}"]:checked`,
|
|
4407
|
+
);
|
|
4408
|
+
if (checkedInput) {
|
|
4409
|
+
return checkedInput;
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4142
4413
|
return focusableDescendant;
|
|
4143
4414
|
};
|
|
4144
4415
|
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
const
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
if (INPUT_ALLOWED_KEYS.has(event.key)) {
|
|
4164
|
-
return true;
|
|
4165
|
-
}
|
|
4166
|
-
if (INPUT_ARROW_KEYS.has(event.key)) {
|
|
4167
|
-
return !INPUT_TYPES_WITH_ARROW_MEANING.has(target.type);
|
|
4168
|
-
}
|
|
4169
|
-
return false;
|
|
4170
|
-
}
|
|
4171
|
-
if (
|
|
4172
|
-
target.tagName === "TEXTAREA" ||
|
|
4173
|
-
target.contentEditable === "true" ||
|
|
4174
|
-
target.isContentEditable
|
|
4175
|
-
) {
|
|
4176
|
-
return TEXTAREA_ALLOWED_KEYS.has(event.key);
|
|
4177
|
-
}
|
|
4178
|
-
// Don't handle shortcuts when select dropdown is open
|
|
4179
|
-
if (target.tagName === "SELECT") {
|
|
4180
|
-
return false;
|
|
4416
|
+
/**
|
|
4417
|
+
* Returns the browser's default action for a keyboard event on its target element.
|
|
4418
|
+
*
|
|
4419
|
+
* Possible return values:
|
|
4420
|
+
* - `"activate"` — Space/Enter triggers the element's primary action (button click, checkbox toggle, picker open…)
|
|
4421
|
+
* - `"form_submit"` — Enter submits the enclosing form (single-line inputs)
|
|
4422
|
+
* - `"dismiss"` — Escape closes a dialog, clears a search field, collapses a dropdown
|
|
4423
|
+
* - `"focus_nav"` — key moves focus (Tab, arrow keys in a radio/checkbox group)
|
|
4424
|
+
* - `"value_change"` — key increments/decrements the field value (range, number, date…)
|
|
4425
|
+
* - `"cursor_move"` — key moves the text cursor within the field
|
|
4426
|
+
* - `"type"` — key produces or deletes text content
|
|
4427
|
+
* - `"scroll"` — key scrolls the page or a scrollable container
|
|
4428
|
+
* - `""` — no meaningful browser default; safe to intercept freely
|
|
4429
|
+
*/
|
|
4430
|
+
const normalizeKeyboardKey = (rawKey) => {
|
|
4431
|
+
// The browser sends " " for the Space bar; map it to the friendly name "space"
|
|
4432
|
+
if (rawKey === " ") {
|
|
4433
|
+
return "space";
|
|
4181
4434
|
}
|
|
4182
|
-
|
|
4435
|
+
return rawKey.toLowerCase();
|
|
4436
|
+
};
|
|
4437
|
+
|
|
4438
|
+
const getKeyboardEventDefaultAction = (keyboardEvent) => {
|
|
4439
|
+
const target = keyboardEvent.target;
|
|
4440
|
+
const key = normalizeKeyboardKey(keyboardEvent.key);
|
|
4441
|
+
|
|
4442
|
+
// Nothing special occurs when the target or an ancestor is disabled/inert
|
|
4183
4443
|
if (
|
|
4184
4444
|
target.disabled ||
|
|
4185
4445
|
target.closest("[disabled]") ||
|
|
4186
4446
|
target.inert ||
|
|
4187
4447
|
target.closest("[inert]")
|
|
4188
4448
|
) {
|
|
4449
|
+
return "";
|
|
4450
|
+
}
|
|
4451
|
+
for (const { test, keys, fallback } of DEFAULT_BEHAVIORS) {
|
|
4452
|
+
if (!test(target)) {
|
|
4453
|
+
continue;
|
|
4454
|
+
}
|
|
4455
|
+
if (Object.hasOwn(keys, key)) {
|
|
4456
|
+
const value = keys[key];
|
|
4457
|
+
const defaultActionForKey =
|
|
4458
|
+
typeof value === "function" ? value(keyboardEvent) : value;
|
|
4459
|
+
if (defaultActionForKey !== undefined) {
|
|
4460
|
+
return defaultActionForKey;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
if (fallback === undefined) {
|
|
4464
|
+
// This entry only handles specific keys — keep looking for other entries
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
4467
|
+
const defaultAction =
|
|
4468
|
+
typeof fallback === "function" ? fallback(keyboardEvent) : fallback;
|
|
4469
|
+
if (defaultAction !== undefined) {
|
|
4470
|
+
return defaultAction;
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
return "";
|
|
4474
|
+
};
|
|
4475
|
+
|
|
4476
|
+
const isTypingIntent = (e) => {
|
|
4477
|
+
// Modifier keys used for shortcuts: skip
|
|
4478
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4189
4479
|
return false;
|
|
4190
4480
|
}
|
|
4191
|
-
|
|
4481
|
+
const key = normalizeKeyboardKey(e.key);
|
|
4482
|
+
// Single printable character — the user is typing
|
|
4483
|
+
if (e.key.length === 1) {
|
|
4484
|
+
return true;
|
|
4485
|
+
}
|
|
4486
|
+
// Editing keys that would modify the text
|
|
4487
|
+
if (key === "backspace" || key === "delete") {
|
|
4488
|
+
return true;
|
|
4489
|
+
}
|
|
4490
|
+
return false;
|
|
4192
4491
|
};
|
|
4193
4492
|
|
|
4493
|
+
const DEFAULT_BEHAVIORS = [
|
|
4494
|
+
{
|
|
4495
|
+
test: () => true,
|
|
4496
|
+
keys: {
|
|
4497
|
+
// Tab moves focus on any element
|
|
4498
|
+
tab: "focus_nav",
|
|
4499
|
+
// Escape dismisses on any element (dialog, search clear, dropdown close, etc.)
|
|
4500
|
+
escape: "dismiss",
|
|
4501
|
+
},
|
|
4502
|
+
// no fallback: only claims Tab/Escape, other keys continue to next entries
|
|
4503
|
+
},
|
|
4504
|
+
{
|
|
4505
|
+
test: (el) => el.matches("input[type='radio'], input[type='checkbox']"),
|
|
4506
|
+
keys: {
|
|
4507
|
+
space: "activate",
|
|
4508
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4509
|
+
arrowleft: "focus_nav",
|
|
4510
|
+
arrowright: "focus_nav",
|
|
4511
|
+
arrowup: "focus_nav",
|
|
4512
|
+
arrowdown: "focus_nav",
|
|
4513
|
+
},
|
|
4514
|
+
},
|
|
4515
|
+
{
|
|
4516
|
+
test: (el) =>
|
|
4517
|
+
el.matches(
|
|
4518
|
+
"input:not([type]), input[type='text'], input[type='search'], input[type='url'], input[type='email'], input[type='password'], input[type='tel']",
|
|
4519
|
+
),
|
|
4520
|
+
keys: {
|
|
4521
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4522
|
+
arrowleft: "cursor_move",
|
|
4523
|
+
arrowright: "cursor_move",
|
|
4524
|
+
arrowup: "cursor_move",
|
|
4525
|
+
arrowdown: "cursor_move",
|
|
4526
|
+
home: "cursor_move",
|
|
4527
|
+
end: "cursor_move",
|
|
4528
|
+
},
|
|
4529
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4530
|
+
},
|
|
4531
|
+
{
|
|
4532
|
+
test: (el) => el.matches("input[type='range']"),
|
|
4533
|
+
keys: {
|
|
4534
|
+
space: "scroll",
|
|
4535
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4536
|
+
arrowleft: "value_change",
|
|
4537
|
+
arrowright: "value_change",
|
|
4538
|
+
arrowup: "value_change",
|
|
4539
|
+
arrowdown: "value_change",
|
|
4540
|
+
home: "value_change",
|
|
4541
|
+
end: "value_change",
|
|
4542
|
+
pageup: "value_change",
|
|
4543
|
+
pagedown: "value_change",
|
|
4544
|
+
},
|
|
4545
|
+
},
|
|
4546
|
+
{
|
|
4547
|
+
test: (el) => el.matches("input[type='number']"),
|
|
4548
|
+
keys: {
|
|
4549
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4550
|
+
arrowleft: "cursor_move",
|
|
4551
|
+
arrowright: "cursor_move",
|
|
4552
|
+
arrowup: "value_change",
|
|
4553
|
+
arrowdown: "value_change",
|
|
4554
|
+
home: "cursor_move",
|
|
4555
|
+
end: "cursor_move",
|
|
4556
|
+
},
|
|
4557
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4558
|
+
},
|
|
4559
|
+
{
|
|
4560
|
+
test: (el) =>
|
|
4561
|
+
el.matches(
|
|
4562
|
+
"input[type='date'], input[type='time'], input[type='datetime-local'], input[type='month'], input[type='week']",
|
|
4563
|
+
),
|
|
4564
|
+
keys: {
|
|
4565
|
+
space: "activate",
|
|
4566
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4567
|
+
arrowleft: "value_change",
|
|
4568
|
+
arrowright: "value_change",
|
|
4569
|
+
arrowup: "value_change",
|
|
4570
|
+
arrowdown: "value_change",
|
|
4571
|
+
},
|
|
4572
|
+
},
|
|
4573
|
+
{
|
|
4574
|
+
// Color input: Space opens the color picker, Enter submits the form
|
|
4575
|
+
test: (el) => el.matches("input[type='color']"),
|
|
4576
|
+
keys: {
|
|
4577
|
+
space: "activate",
|
|
4578
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4579
|
+
},
|
|
4580
|
+
},
|
|
4581
|
+
{
|
|
4582
|
+
// File input: Space opens the picker, Enter submits the form
|
|
4583
|
+
test: (el) => el.matches("input[type='file']"),
|
|
4584
|
+
keys: {
|
|
4585
|
+
space: "activate",
|
|
4586
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4587
|
+
},
|
|
4588
|
+
},
|
|
4589
|
+
{
|
|
4590
|
+
// Generic INPUT fallback for any remaining input types
|
|
4591
|
+
test: (el) => el.tagName === "INPUT",
|
|
4592
|
+
keys: {},
|
|
4593
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4594
|
+
},
|
|
4595
|
+
{
|
|
4596
|
+
test: (el) =>
|
|
4597
|
+
el.tagName === "TEXTAREA" ||
|
|
4598
|
+
el.contentEditable === "true" ||
|
|
4599
|
+
el.isContentEditable,
|
|
4600
|
+
keys: {
|
|
4601
|
+
enter: "type",
|
|
4602
|
+
arrowleft: "cursor_move",
|
|
4603
|
+
arrowright: "cursor_move",
|
|
4604
|
+
arrowup: "cursor_move",
|
|
4605
|
+
arrowdown: "cursor_move",
|
|
4606
|
+
home: "cursor_move",
|
|
4607
|
+
end: "cursor_move",
|
|
4608
|
+
},
|
|
4609
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4610
|
+
},
|
|
4611
|
+
{
|
|
4612
|
+
// Buttons and links: Space/Enter trigger the element's default action
|
|
4613
|
+
test: (el) =>
|
|
4614
|
+
el.tagName === "BUTTON" ||
|
|
4615
|
+
el.tagName === "A" ||
|
|
4616
|
+
el.getAttribute("role") === "button",
|
|
4617
|
+
keys: {
|
|
4618
|
+
space: "activate",
|
|
4619
|
+
enter: "activate",
|
|
4620
|
+
},
|
|
4621
|
+
},
|
|
4622
|
+
{
|
|
4623
|
+
// details/summary: Space/Enter toggle the disclosure widget
|
|
4624
|
+
test: (el) => el.tagName === "DETAILS" || el.tagName === "SUMMARY",
|
|
4625
|
+
keys: {
|
|
4626
|
+
space: "activate",
|
|
4627
|
+
enter: "activate",
|
|
4628
|
+
},
|
|
4629
|
+
},
|
|
4630
|
+
{
|
|
4631
|
+
// SELECT: don't intercept anything while the dropdown may be open
|
|
4632
|
+
test: (el) => el.tagName === "SELECT",
|
|
4633
|
+
keys: {},
|
|
4634
|
+
},
|
|
4635
|
+
{
|
|
4636
|
+
// Non-interactive elements: browser scrolls on Space and arrow keys
|
|
4637
|
+
test: () => true,
|
|
4638
|
+
keys: {
|
|
4639
|
+
space: "scroll",
|
|
4640
|
+
arrowup: "scroll",
|
|
4641
|
+
arrowdown: "scroll",
|
|
4642
|
+
arrowleft: "scroll",
|
|
4643
|
+
arrowright: "scroll",
|
|
4644
|
+
pageup: "scroll",
|
|
4645
|
+
pagedown: "scroll",
|
|
4646
|
+
home: "scroll",
|
|
4647
|
+
end: "scroll",
|
|
4648
|
+
},
|
|
4649
|
+
},
|
|
4650
|
+
];
|
|
4651
|
+
|
|
4194
4652
|
// WeakMap to store focus group metadata
|
|
4195
4653
|
const focusGroupRegistry = new WeakMap();
|
|
4196
4654
|
|
|
@@ -4232,12 +4690,50 @@ const markFocusNav = (event) => {
|
|
|
4232
4690
|
focusNavEventMarker.mark(event);
|
|
4233
4691
|
};
|
|
4234
4692
|
|
|
4693
|
+
/**
|
|
4694
|
+
* Performs arrow-key navigation within a focus group element.
|
|
4695
|
+
*
|
|
4696
|
+
* Called on every keydown event inside the group. Decides whether the pressed
|
|
4697
|
+
* key should move focus to another element, and if so, which one.
|
|
4698
|
+
*
|
|
4699
|
+
* @param {KeyboardEvent} event - The keydown event.
|
|
4700
|
+
* @param {Element} element - The focus-group root element.
|
|
4701
|
+
* @param {object} [options]
|
|
4702
|
+
* @param {string} [options.name] - Optional group name used for ancestor delegation.
|
|
4703
|
+
* @param {boolean} [options.excludeAriaHidden=true] - Skip elements hidden from the accessibility tree.
|
|
4704
|
+
* @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
|
|
4705
|
+
* "x" = left/right only, "y" = up/down only, "both" = all four arrows.
|
|
4706
|
+
* @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
|
|
4707
|
+
* Omit or pass undefined for no looping on either axis.
|
|
4708
|
+
* @param {string} [options.xSelector] - CSS selector that candidates must match
|
|
4709
|
+
* when navigating on the x axis. Omit to allow any focusable element.
|
|
4710
|
+
* @param {string} [options.ySelector] - CSS selector that candidates must match
|
|
4711
|
+
* when navigating on the y axis. Omit to allow any focusable element.
|
|
4712
|
+
* @returns {boolean} True if the event was handled (focus moved or default prevented).
|
|
4713
|
+
*/
|
|
4235
4714
|
const performArrowNavigation = (
|
|
4236
4715
|
event,
|
|
4237
4716
|
element,
|
|
4238
|
-
{
|
|
4717
|
+
{
|
|
4718
|
+
name,
|
|
4719
|
+
excludeAriaHidden,
|
|
4720
|
+
// Which axes are active: "x", "y", or "both" (default)
|
|
4721
|
+
direction = "both",
|
|
4722
|
+
// Which axes loop at boundaries: "x", "y", "both", or undefined (no looping)
|
|
4723
|
+
wrap,
|
|
4724
|
+
// CSS selector to restrict candidates on each axis
|
|
4725
|
+
xSelector,
|
|
4726
|
+
ySelector,
|
|
4727
|
+
} = {},
|
|
4239
4728
|
) => {
|
|
4240
|
-
|
|
4729
|
+
const defaultAction = getKeyboardEventDefaultAction(event);
|
|
4730
|
+
// A focus group takes over arrow-key navigation entirely, including cases
|
|
4731
|
+
// where the browser would otherwise scroll (e.g. arrow keys on a <button>).
|
|
4732
|
+
const canIntercept =
|
|
4733
|
+
defaultAction === "focus_nav" ||
|
|
4734
|
+
defaultAction === "scroll" ||
|
|
4735
|
+
!defaultAction;
|
|
4736
|
+
if (!canIntercept) {
|
|
4241
4737
|
return false;
|
|
4242
4738
|
}
|
|
4243
4739
|
const activeElement = document.activeElement;
|
|
@@ -4256,7 +4752,23 @@ const performArrowNavigation = (
|
|
|
4256
4752
|
// Grid navigation: we support only TABLE element for now
|
|
4257
4753
|
// A role="table" or an element with display: table could be used too but for now we need only TABLE support
|
|
4258
4754
|
if (element.tagName === "TABLE") {
|
|
4259
|
-
const
|
|
4755
|
+
const tablePredicate = (candidate) => {
|
|
4756
|
+
if (!candidate.matches) {
|
|
4757
|
+
return false;
|
|
4758
|
+
}
|
|
4759
|
+
if (candidate.getAttribute("navi-focusnav") === "ignore") {
|
|
4760
|
+
return false;
|
|
4761
|
+
}
|
|
4762
|
+
if (!elementIsFocusable(candidate, { excludeAriaHidden })) {
|
|
4763
|
+
return false;
|
|
4764
|
+
}
|
|
4765
|
+
return true;
|
|
4766
|
+
};
|
|
4767
|
+
const tableLoop = wrap === "both" || wrap === "x" || wrap === "y";
|
|
4768
|
+
const targetInGrid = getTargetInTableFocusGroup(event, element, {
|
|
4769
|
+
loop: tableLoop,
|
|
4770
|
+
predicate: tablePredicate,
|
|
4771
|
+
});
|
|
4260
4772
|
if (!targetInGrid) {
|
|
4261
4773
|
return false;
|
|
4262
4774
|
}
|
|
@@ -4264,12 +4776,67 @@ const performArrowNavigation = (
|
|
|
4264
4776
|
return true;
|
|
4265
4777
|
}
|
|
4266
4778
|
|
|
4779
|
+
// Linear navigation: detect which axis the pressed key belongs to.
|
|
4780
|
+
const isVerticalKey = event.key === "ArrowUp" || event.key === "ArrowDown";
|
|
4781
|
+
const isHorizontalKey =
|
|
4782
|
+
event.key === "ArrowLeft" || event.key === "ArrowRight";
|
|
4783
|
+
if (!isVerticalKey && !isHorizontalKey) {
|
|
4784
|
+
return false;
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
// Check whether this axis is enabled and resolve its loop + cssSelector.
|
|
4788
|
+
let axisDirection;
|
|
4789
|
+
let axisLoop;
|
|
4790
|
+
let axisCssSelector;
|
|
4791
|
+
if (isVerticalKey) {
|
|
4792
|
+
if (direction !== "both" && direction !== "y") {
|
|
4793
|
+
return false;
|
|
4794
|
+
}
|
|
4795
|
+
axisDirection = "vertical";
|
|
4796
|
+
axisLoop = wrap === "both" || wrap === "y";
|
|
4797
|
+
axisCssSelector = ySelector;
|
|
4798
|
+
} else {
|
|
4799
|
+
if (direction !== "both" && direction !== "x") {
|
|
4800
|
+
return false;
|
|
4801
|
+
}
|
|
4802
|
+
axisDirection = "horizontal";
|
|
4803
|
+
axisLoop = wrap === "both" || wrap === "x";
|
|
4804
|
+
axisCssSelector = xSelector;
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
const predicate = (candidate) => {
|
|
4808
|
+
if (typeof candidate.matches !== "function") {
|
|
4809
|
+
// Guard against nodes without matches() (e.g. text nodes).
|
|
4810
|
+
return false;
|
|
4811
|
+
}
|
|
4812
|
+
if (candidate.getAttribute("navi-focusnav") === "ignore") {
|
|
4813
|
+
return false;
|
|
4814
|
+
}
|
|
4815
|
+
// cssSelector check first: cheaper than elementIsFocusable.
|
|
4816
|
+
if (axisCssSelector && !candidate.matches(axisCssSelector)) {
|
|
4817
|
+
return false;
|
|
4818
|
+
}
|
|
4819
|
+
if (!elementIsFocusable(candidate, { excludeAriaHidden })) {
|
|
4820
|
+
return false;
|
|
4821
|
+
}
|
|
4822
|
+
return true;
|
|
4823
|
+
};
|
|
4824
|
+
|
|
4267
4825
|
const targetInLinearGroup = getTargetInLinearFocusGroup(event, element, {
|
|
4268
|
-
direction,
|
|
4269
|
-
loop,
|
|
4826
|
+
direction: axisDirection,
|
|
4827
|
+
loop: axisLoop,
|
|
4270
4828
|
name,
|
|
4829
|
+
predicate,
|
|
4271
4830
|
});
|
|
4272
4831
|
if (!targetInLinearGroup) {
|
|
4832
|
+
// We decided not to loop, but the browser may loop anyway for certain element
|
|
4833
|
+
// types (e.g. radio inputs cycle through their name group on arrow keys).
|
|
4834
|
+
// Return true when the browser would do something we explicitly chose not to
|
|
4835
|
+
// do, so the caller can preventDefault to enforce our decision.
|
|
4836
|
+
if (!axisLoop && browserWouldLoopWithoutPreventDefault(activeElement)) {
|
|
4837
|
+
event.preventDefault();
|
|
4838
|
+
markFocusNav(event);
|
|
4839
|
+
}
|
|
4273
4840
|
return false;
|
|
4274
4841
|
}
|
|
4275
4842
|
onTargetToFocus(targetInLinearGroup);
|
|
@@ -4279,7 +4846,7 @@ const performArrowNavigation = (
|
|
|
4279
4846
|
const getTargetInLinearFocusGroup = (
|
|
4280
4847
|
event,
|
|
4281
4848
|
element,
|
|
4282
|
-
{ direction, loop, name },
|
|
4849
|
+
{ direction, loop, name, predicate },
|
|
4283
4850
|
) => {
|
|
4284
4851
|
const activeElement = document.activeElement;
|
|
4285
4852
|
|
|
@@ -4287,7 +4854,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4287
4854
|
const isJumpToEnd = event.metaKey || event.ctrlKey;
|
|
4288
4855
|
|
|
4289
4856
|
if (isJumpToEnd) {
|
|
4290
|
-
return getJumpToEndTargetLinear(event, element, direction);
|
|
4857
|
+
return getJumpToEndTargetLinear(event, element, direction, predicate);
|
|
4291
4858
|
}
|
|
4292
4859
|
|
|
4293
4860
|
const isForward = isForwardArrow(event, direction);
|
|
@@ -4297,7 +4864,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4297
4864
|
if (!isBackwardArrow(event, direction)) {
|
|
4298
4865
|
break backward;
|
|
4299
4866
|
}
|
|
4300
|
-
const previousElement = findBefore(activeElement,
|
|
4867
|
+
const previousElement = findBefore(activeElement, predicate, {
|
|
4301
4868
|
root: element,
|
|
4302
4869
|
});
|
|
4303
4870
|
if (previousElement) {
|
|
@@ -4305,15 +4872,13 @@ const getTargetInLinearFocusGroup = (
|
|
|
4305
4872
|
}
|
|
4306
4873
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4307
4874
|
name,
|
|
4875
|
+
predicate,
|
|
4308
4876
|
});
|
|
4309
4877
|
if (ancestorTarget) {
|
|
4310
4878
|
return ancestorTarget;
|
|
4311
4879
|
}
|
|
4312
4880
|
if (loop) {
|
|
4313
|
-
const lastFocusableElement = findLastDescendant(
|
|
4314
|
-
element,
|
|
4315
|
-
elementIsFocusable,
|
|
4316
|
-
);
|
|
4881
|
+
const lastFocusableElement = findLastDescendant(element, predicate);
|
|
4317
4882
|
if (lastFocusableElement) {
|
|
4318
4883
|
return lastFocusableElement;
|
|
4319
4884
|
}
|
|
@@ -4326,7 +4891,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4326
4891
|
if (!isForward) {
|
|
4327
4892
|
break forward;
|
|
4328
4893
|
}
|
|
4329
|
-
const nextElement = findAfter(activeElement,
|
|
4894
|
+
const nextElement = findAfter(activeElement, predicate, {
|
|
4330
4895
|
root: element,
|
|
4331
4896
|
});
|
|
4332
4897
|
if (nextElement) {
|
|
@@ -4334,13 +4899,14 @@ const getTargetInLinearFocusGroup = (
|
|
|
4334
4899
|
}
|
|
4335
4900
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4336
4901
|
name,
|
|
4902
|
+
predicate,
|
|
4337
4903
|
});
|
|
4338
4904
|
if (ancestorTarget) {
|
|
4339
4905
|
return ancestorTarget;
|
|
4340
4906
|
}
|
|
4341
4907
|
if (loop) {
|
|
4342
4908
|
// No next element, wrap to first focusable in group
|
|
4343
|
-
const firstFocusableElement = findDescendant(element,
|
|
4909
|
+
const firstFocusableElement = findDescendant(element, predicate);
|
|
4344
4910
|
if (firstFocusableElement) {
|
|
4345
4911
|
return firstFocusableElement;
|
|
4346
4912
|
}
|
|
@@ -4351,7 +4917,11 @@ const getTargetInLinearFocusGroup = (
|
|
|
4351
4917
|
return null;
|
|
4352
4918
|
};
|
|
4353
4919
|
// Find parent focus group with the same name and try delegation
|
|
4354
|
-
const delegateArrowNavigation = (
|
|
4920
|
+
const delegateArrowNavigation = (
|
|
4921
|
+
event,
|
|
4922
|
+
currentElement,
|
|
4923
|
+
{ name, predicate },
|
|
4924
|
+
) => {
|
|
4355
4925
|
let ancestorElement = currentElement.parentElement;
|
|
4356
4926
|
while (ancestorElement) {
|
|
4357
4927
|
const ancestorFocusGroup = getFocusGroup(ancestorElement);
|
|
@@ -4372,6 +4942,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4372
4942
|
direction: ancestorFocusGroup.direction,
|
|
4373
4943
|
loop: ancestorFocusGroup.loop,
|
|
4374
4944
|
name: ancestorFocusGroup.name,
|
|
4945
|
+
predicate,
|
|
4375
4946
|
});
|
|
4376
4947
|
}
|
|
4377
4948
|
}
|
|
@@ -4379,7 +4950,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4379
4950
|
};
|
|
4380
4951
|
|
|
4381
4952
|
// Handle Cmd/Ctrl + arrow keys for linear focus groups to jump to start/end
|
|
4382
|
-
const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
4953
|
+
const getJumpToEndTargetLinear = (event, element, direction, predicate) => {
|
|
4383
4954
|
// Check if this arrow key is valid for the given direction
|
|
4384
4955
|
if (!isForwardArrow(event, direction) && !isBackwardArrow(event, direction)) {
|
|
4385
4956
|
return null;
|
|
@@ -4387,12 +4958,12 @@ const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
|
4387
4958
|
|
|
4388
4959
|
if (isBackwardArrow(event, direction)) {
|
|
4389
4960
|
// Jump to first focusable element in the group
|
|
4390
|
-
return findDescendant(element,
|
|
4961
|
+
return findDescendant(element, predicate);
|
|
4391
4962
|
}
|
|
4392
4963
|
|
|
4393
4964
|
if (isForwardArrow(event, direction)) {
|
|
4394
4965
|
// Jump to last focusable element in the group
|
|
4395
|
-
return findLastDescendant(element,
|
|
4966
|
+
return findLastDescendant(element, predicate);
|
|
4396
4967
|
}
|
|
4397
4968
|
|
|
4398
4969
|
return null;
|
|
@@ -4415,9 +4986,21 @@ const isForwardArrow = (event, direction = "both") => {
|
|
|
4415
4986
|
return forwardKeys[direction]?.includes(event.key) ?? false;
|
|
4416
4987
|
};
|
|
4417
4988
|
|
|
4989
|
+
// We decided not to loop, but the browser may loop anyway for certain element
|
|
4990
|
+
// types (e.g. radio inputs cycle through their name group on arrow keys).
|
|
4991
|
+
// Return true when the browser would do something we explicitly chose not to
|
|
4992
|
+
// do, so the caller can preventDefault to enforce our decision.
|
|
4993
|
+
const browserWouldLoopWithoutPreventDefault = (element) => {
|
|
4994
|
+
if (element.tagName === "INPUT" && element.type === "radio") {
|
|
4995
|
+
// Radio: browser cycles through same-name group on arrow keys
|
|
4996
|
+
return true;
|
|
4997
|
+
}
|
|
4998
|
+
return false;
|
|
4999
|
+
};
|
|
5000
|
+
|
|
4418
5001
|
// Handle arrow navigation inside an HTMLTableElement as a grid.
|
|
4419
5002
|
// Moves focus to adjacent cell in the direction of the arrow key.
|
|
4420
|
-
const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
5003
|
+
const getTargetInTableFocusGroup = (event, table, { loop, predicate }) => {
|
|
4421
5004
|
const arrowKey = event.key;
|
|
4422
5005
|
|
|
4423
5006
|
// Only handle arrow keys
|
|
@@ -4435,7 +5018,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4435
5018
|
|
|
4436
5019
|
// If we're not currently in a table cell, try to focus the first focusable element in the table
|
|
4437
5020
|
if (!currentCell || !table.contains(currentCell)) {
|
|
4438
|
-
return findDescendant(table,
|
|
5021
|
+
return findDescendant(table, predicate) || null;
|
|
4439
5022
|
}
|
|
4440
5023
|
|
|
4441
5024
|
// Get the current position in the table grid
|
|
@@ -4455,6 +5038,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4455
5038
|
allRows,
|
|
4456
5039
|
currentRowIndex,
|
|
4457
5040
|
currentColumnIndex,
|
|
5041
|
+
predicate,
|
|
4458
5042
|
);
|
|
4459
5043
|
}
|
|
4460
5044
|
|
|
@@ -4469,7 +5053,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4469
5053
|
|
|
4470
5054
|
// Find the first cell that is itself focusable
|
|
4471
5055
|
for (const candidateCell of candidateCells) {
|
|
4472
|
-
if (
|
|
5056
|
+
if (predicate(candidateCell)) {
|
|
4473
5057
|
return candidateCell;
|
|
4474
5058
|
}
|
|
4475
5059
|
}
|
|
@@ -4483,6 +5067,7 @@ const getJumpToEndTarget = (
|
|
|
4483
5067
|
allRows,
|
|
4484
5068
|
currentRowIndex,
|
|
4485
5069
|
currentColumnIndex,
|
|
5070
|
+
predicate,
|
|
4486
5071
|
) => {
|
|
4487
5072
|
if (arrowKey === "ArrowRight") {
|
|
4488
5073
|
// Jump to last focusable cell in current row
|
|
@@ -4493,7 +5078,7 @@ const getJumpToEndTarget = (
|
|
|
4493
5078
|
const cells = Array.from(currentRow.cells);
|
|
4494
5079
|
for (let i = cells.length - 1; i >= 0; i--) {
|
|
4495
5080
|
const cell = cells[i];
|
|
4496
|
-
if (
|
|
5081
|
+
if (predicate(cell)) {
|
|
4497
5082
|
return cell;
|
|
4498
5083
|
}
|
|
4499
5084
|
}
|
|
@@ -4507,7 +5092,7 @@ const getJumpToEndTarget = (
|
|
|
4507
5092
|
|
|
4508
5093
|
const cells = Array.from(currentRow.cells);
|
|
4509
5094
|
for (const cell of cells) {
|
|
4510
|
-
if (
|
|
5095
|
+
if (predicate(cell)) {
|
|
4511
5096
|
return cell;
|
|
4512
5097
|
}
|
|
4513
5098
|
}
|
|
@@ -4519,7 +5104,7 @@ const getJumpToEndTarget = (
|
|
|
4519
5104
|
for (let rowIndex = allRows.length - 1; rowIndex >= 0; rowIndex--) {
|
|
4520
5105
|
const row = allRows[rowIndex];
|
|
4521
5106
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4522
|
-
if (cell &&
|
|
5107
|
+
if (cell && predicate(cell)) {
|
|
4523
5108
|
return cell;
|
|
4524
5109
|
}
|
|
4525
5110
|
}
|
|
@@ -4531,7 +5116,7 @@ const getJumpToEndTarget = (
|
|
|
4531
5116
|
for (let rowIndex = 0; rowIndex < allRows.length; rowIndex++) {
|
|
4532
5117
|
const row = allRows[rowIndex];
|
|
4533
5118
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4534
|
-
if (cell &&
|
|
5119
|
+
if (cell && predicate(cell)) {
|
|
4535
5120
|
return cell;
|
|
4536
5121
|
}
|
|
4537
5122
|
}
|
|
@@ -4812,6 +5397,7 @@ const performTabNavigation = (
|
|
|
4812
5397
|
rootElement = document.body,
|
|
4813
5398
|
outsideOfElement = null,
|
|
4814
5399
|
debug = () => {},
|
|
5400
|
+
excludeAriaHidden,
|
|
4815
5401
|
} = {},
|
|
4816
5402
|
) => {
|
|
4817
5403
|
if (!isTabEvent$1(event)) {
|
|
@@ -4834,6 +5420,12 @@ const performTabNavigation = (
|
|
|
4834
5420
|
markFocusNav(event);
|
|
4835
5421
|
targetToFocus.focus();
|
|
4836
5422
|
};
|
|
5423
|
+
const isFocusableByTab = (element) => {
|
|
5424
|
+
if (hasNegativeTabIndex(element)) {
|
|
5425
|
+
return false;
|
|
5426
|
+
}
|
|
5427
|
+
return elementIsFocusable(element, { excludeAriaHidden });
|
|
5428
|
+
};
|
|
4837
5429
|
|
|
4838
5430
|
const predicate = (candidate) => {
|
|
4839
5431
|
const canBeFocusedByTab = isFocusableByTab(candidate);
|
|
@@ -4907,12 +5499,6 @@ const performTabNavigation = (
|
|
|
4907
5499
|
|
|
4908
5500
|
const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
|
|
4909
5501
|
|
|
4910
|
-
const isFocusableByTab = (element) => {
|
|
4911
|
-
if (hasNegativeTabIndex(element)) {
|
|
4912
|
-
return false;
|
|
4913
|
-
}
|
|
4914
|
-
return elementIsFocusable(element);
|
|
4915
|
-
};
|
|
4916
5502
|
const hasNegativeTabIndex = (element) => {
|
|
4917
5503
|
return (
|
|
4918
5504
|
element.hasAttribute &&
|
|
@@ -4931,14 +5517,47 @@ const hasNegativeTabIndex = (element) => {
|
|
|
4931
5517
|
*/
|
|
4932
5518
|
|
|
4933
5519
|
|
|
5520
|
+
/**
|
|
5521
|
+
* Initialises keyboard navigation for a focus group.
|
|
5522
|
+
*
|
|
5523
|
+
* Sets up two keyboard behaviours on the element:
|
|
5524
|
+
* - **Tab**: exits the group, moving focus to the next/previous focusable
|
|
5525
|
+
* element outside the group (standard skip-group behaviour).
|
|
5526
|
+
* - **Arrow keys**: moves focus between focusable descendants according to
|
|
5527
|
+
* the configured direction, wrapping and selector constraints.
|
|
5528
|
+
*
|
|
5529
|
+
* @param {Element} element - The focus-group root element.
|
|
5530
|
+
* @param {object} [options]
|
|
5531
|
+
* @param {boolean} [options.skipTab=true] - When true, Tab exits the group
|
|
5532
|
+
* instead of moving through its children one by one.
|
|
5533
|
+
* @param {string} [options.name] - Optional name shared between related groups
|
|
5534
|
+
* to enable delegation (focus jumps from one named group to another).
|
|
5535
|
+
* @param {boolean} [options.excludeAriaHidden=true] - Skip elements that are
|
|
5536
|
+
* hidden from the accessibility tree (aria-hidden).
|
|
5537
|
+
* @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
|
|
5538
|
+
* "x" = left/right only, "y" = up/down only, "both" = all four arrows.
|
|
5539
|
+
* @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
|
|
5540
|
+
* Omit or pass undefined for no looping on either axis.
|
|
5541
|
+
* @param {string} [options.xSelector] - CSS selector that candidates must match
|
|
5542
|
+
* when navigating on the x axis. Omit to allow any focusable element.
|
|
5543
|
+
* @param {string} [options.ySelector] - CSS selector that candidates must match
|
|
5544
|
+
* when navigating on the y axis. Omit to allow any focusable element.
|
|
5545
|
+
* @returns {{ cleanup: () => void }} Call cleanup() to remove all event listeners.
|
|
5546
|
+
*/
|
|
4934
5547
|
const initFocusGroup = (
|
|
4935
5548
|
element,
|
|
4936
5549
|
{
|
|
4937
|
-
direction = "both",
|
|
4938
5550
|
// extend = true,
|
|
4939
5551
|
skipTab = true,
|
|
4940
|
-
loop = false,
|
|
4941
5552
|
name, // Can be undefined for implicit ancestor-descendant grouping
|
|
5553
|
+
excludeAriaHidden = true,
|
|
5554
|
+
// Which axes are active: "x", "y", or "both" (default)
|
|
5555
|
+
direction = "both",
|
|
5556
|
+
// Which axes loop at boundaries: "x", "y", "both", or undefined (no looping)
|
|
5557
|
+
wrap,
|
|
5558
|
+
// CSS selector to restrict candidates on each axis
|
|
5559
|
+
xSelector,
|
|
5560
|
+
ySelector,
|
|
4942
5561
|
} = {},
|
|
4943
5562
|
) => {
|
|
4944
5563
|
const cleanupCallbackSet = new Set();
|
|
@@ -4952,7 +5571,6 @@ const initFocusGroup = (
|
|
|
4952
5571
|
// Store focus group data in registry
|
|
4953
5572
|
const removeFocusGroup = setFocusGroup(element, {
|
|
4954
5573
|
direction,
|
|
4955
|
-
loop,
|
|
4956
5574
|
name, // Store undefined as-is for implicit grouping
|
|
4957
5575
|
});
|
|
4958
5576
|
cleanupCallbackSet.add(removeFocusGroup);
|
|
@@ -4966,7 +5584,10 @@ const initFocusGroup = (
|
|
|
4966
5584
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
4967
5585
|
return;
|
|
4968
5586
|
}
|
|
4969
|
-
performTabNavigation(event, {
|
|
5587
|
+
performTabNavigation(event, {
|
|
5588
|
+
outsideOfElement: element,
|
|
5589
|
+
excludeAriaHidden,
|
|
5590
|
+
});
|
|
4970
5591
|
};
|
|
4971
5592
|
// Handle Tab navigation (exit group)
|
|
4972
5593
|
element.addEventListener("keydown", handleTabKeyDown, {
|
|
@@ -4990,7 +5611,14 @@ const initFocusGroup = (
|
|
|
4990
5611
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
4991
5612
|
return;
|
|
4992
5613
|
}
|
|
4993
|
-
performArrowNavigation(event, element, {
|
|
5614
|
+
performArrowNavigation(event, element, {
|
|
5615
|
+
name,
|
|
5616
|
+
excludeAriaHidden,
|
|
5617
|
+
direction,
|
|
5618
|
+
wrap,
|
|
5619
|
+
xSelector,
|
|
5620
|
+
ySelector,
|
|
5621
|
+
});
|
|
4994
5622
|
};
|
|
4995
5623
|
element.addEventListener("keydown", handleArrowKeyDown, {
|
|
4996
5624
|
// we must use capture: false to let chance for other part of the code
|
|
@@ -5405,6 +6033,9 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
|
|
|
5405
6033
|
}
|
|
5406
6034
|
return null;
|
|
5407
6035
|
}
|
|
6036
|
+
if (element.hasAttribute("popover") && element.matches(":popover-open")) {
|
|
6037
|
+
return getScrollingElement(element.ownerDocument);
|
|
6038
|
+
}
|
|
5408
6039
|
const position = getStyle(element, "position");
|
|
5409
6040
|
if (position === "fixed") {
|
|
5410
6041
|
return getScrollingElement(element.ownerDocument);
|
|
@@ -6235,13 +6866,19 @@ const getPaddingSizes = (element) => {
|
|
|
6235
6866
|
const trapScrollInside = (element) => {
|
|
6236
6867
|
const cleanupCallbackSet = new Set();
|
|
6237
6868
|
const lockScroll = (el) => {
|
|
6869
|
+
const savedScrollTop = el.scrollTop;
|
|
6870
|
+
const savedScrollLeft = el.scrollLeft;
|
|
6238
6871
|
const scrollbarGutter = getStyle(el, "scrollbar-gutter");
|
|
6239
6872
|
const hasScrollbarGutterStrategy =
|
|
6240
6873
|
scrollbarGutter && scrollbarGutter !== "auto";
|
|
6241
6874
|
if (hasScrollbarGutterStrategy) {
|
|
6242
6875
|
// The element manages its own gutter — just hide overflow, no padding needed.
|
|
6243
6876
|
const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
|
|
6244
|
-
cleanupCallbackSet.add(
|
|
6877
|
+
cleanupCallbackSet.add(() => {
|
|
6878
|
+
removeScrollLockStyles();
|
|
6879
|
+
el.scrollTop = savedScrollTop;
|
|
6880
|
+
el.scrollLeft = savedScrollLeft;
|
|
6881
|
+
});
|
|
6245
6882
|
return;
|
|
6246
6883
|
}
|
|
6247
6884
|
const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
|
|
@@ -6251,7 +6888,11 @@ const trapScrollInside = (element) => {
|
|
|
6251
6888
|
"padding-bottom": `${bottom + scrollbarHeight}px`,
|
|
6252
6889
|
"overflow": "hidden",
|
|
6253
6890
|
});
|
|
6254
|
-
cleanupCallbackSet.add(
|
|
6891
|
+
cleanupCallbackSet.add(() => {
|
|
6892
|
+
removeScrollLockStyles();
|
|
6893
|
+
el.scrollTop = savedScrollTop;
|
|
6894
|
+
el.scrollLeft = savedScrollLeft;
|
|
6895
|
+
});
|
|
6255
6896
|
};
|
|
6256
6897
|
let previous = element.previousSibling;
|
|
6257
6898
|
while (previous) {
|
|
@@ -10264,23 +10905,48 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
|
10264
10905
|
/**
|
|
10265
10906
|
* Tracks how much of an element is visible within its scrollable parent and within the
|
|
10266
10907
|
* document viewport. Calls update() on initialization and whenever visibility changes
|
|
10267
|
-
* (scroll, resize, intersection changes).
|
|
10268
|
-
*
|
|
10269
|
-
*
|
|
10270
|
-
*
|
|
10271
|
-
*
|
|
10272
|
-
*
|
|
10273
|
-
*
|
|
10274
|
-
*
|
|
10275
|
-
*
|
|
10908
|
+
* (scroll, resize, intersection changes, ancestor open/close).
|
|
10909
|
+
*
|
|
10910
|
+
* @param {HTMLElement} element - The element to observe.
|
|
10911
|
+
* @param {function(visibleRect: VisibleRect, info: VisibleRectInfo): void} update - Called on every visibility change.
|
|
10912
|
+
*
|
|
10913
|
+
* @typedef {Object} VisibleRect
|
|
10914
|
+
* @property {number} left - Left edge of the visible area, document-relative (px).
|
|
10915
|
+
* @property {number} top - Top edge of the visible area, document-relative (px).
|
|
10916
|
+
* @property {number} right - Right edge of the visible area, document-relative (px).
|
|
10917
|
+
* @property {number} bottom - Bottom edge of the visible area, document-relative (px).
|
|
10918
|
+
* @property {number} width - Width of the visible area (px).
|
|
10919
|
+
* @property {number} height - Height of the visible area (px).
|
|
10920
|
+
* @property {number} visibilityRatio - Fraction of the element's area truly visible on screen (0–1).
|
|
10921
|
+
* For document scroll containers: viewport-clipped fraction.
|
|
10922
|
+
* For custom containers: fraction clipped by both the container and the viewport.
|
|
10923
|
+
* Is 0 when ancestorClosed is true.
|
|
10924
|
+
*
|
|
10925
|
+
* @typedef {Object} VisibleRectInfo
|
|
10926
|
+
* @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
|
|
10927
|
+
* @property {number} width - Raw getBoundingClientRect() width of the element.
|
|
10928
|
+
* @property {number} height - Raw getBoundingClientRect() height of the element.
|
|
10929
|
+
* @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
|
|
10930
|
+
* currently closed so the element is not rendered. All visibleRect values are 0 in that case.
|
|
10931
|
+
* update() is called immediately on ancestor close and again (with false) on reopen.
|
|
10932
|
+
*
|
|
10933
|
+
* update() is called:
|
|
10934
|
+
* - Once synchronously on initialization (event.type = "initialization")
|
|
10935
|
+
* - On document/container scroll, window resize, element resize, intersection changes, touch move
|
|
10936
|
+
* - Immediately when an ancestor popover/dialog/details opens or closes
|
|
10276
10937
|
*
|
|
10277
10938
|
* A bit like https://tetherjs.dev/ but different
|
|
10278
10939
|
*/
|
|
10279
|
-
const visibleRectEffect = (
|
|
10940
|
+
const visibleRectEffect = (
|
|
10941
|
+
element,
|
|
10942
|
+
update,
|
|
10943
|
+
{ event: initialEvent = new CustomEvent("initialization") } = {},
|
|
10944
|
+
) => {
|
|
10280
10945
|
const [teardown, addTeardown] = createPubSub();
|
|
10281
10946
|
const scrollContainer = getScrollContainer(element);
|
|
10282
10947
|
const scrollContainerIsDocument =
|
|
10283
10948
|
scrollContainer === document.documentElement;
|
|
10949
|
+
let ancestorClosedCount = 0;
|
|
10284
10950
|
const check = (event) => {
|
|
10285
10951
|
|
|
10286
10952
|
// 1. Calculate element position relative to scrollable parent
|
|
@@ -10417,10 +11083,11 @@ const visibleRectEffect = (element, update) => {
|
|
|
10417
11083
|
event,
|
|
10418
11084
|
width,
|
|
10419
11085
|
height,
|
|
11086
|
+
ancestorClosed: ancestorClosedCount > 0,
|
|
10420
11087
|
});
|
|
10421
11088
|
};
|
|
10422
11089
|
|
|
10423
|
-
check(
|
|
11090
|
+
check(initialEvent);
|
|
10424
11091
|
|
|
10425
11092
|
const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
|
|
10426
11093
|
{
|
|
@@ -10550,6 +11217,59 @@ const visibleRectEffect = (element, update) => {
|
|
|
10550
11217
|
});
|
|
10551
11218
|
});
|
|
10552
11219
|
}
|
|
11220
|
+
{
|
|
11221
|
+
let current = element.parentElement;
|
|
11222
|
+
while (current) {
|
|
11223
|
+
if (
|
|
11224
|
+
current.hasAttribute("popover") ||
|
|
11225
|
+
current.tagName === "DIALOG" ||
|
|
11226
|
+
current.tagName === "DETAILS"
|
|
11227
|
+
) {
|
|
11228
|
+
const ancestor = current;
|
|
11229
|
+
const isInitiallyClosed =
|
|
11230
|
+
ancestor.tagName === "DIALOG" || ancestor.tagName === "DETAILS"
|
|
11231
|
+
? !ancestor.open
|
|
11232
|
+
: !ancestor.matches(":popover-open");
|
|
11233
|
+
if (isInitiallyClosed) {
|
|
11234
|
+
ancestorClosedCount++;
|
|
11235
|
+
}
|
|
11236
|
+
// eslint-disable-next-line no-loop-func
|
|
11237
|
+
const onToggle = (e) => {
|
|
11238
|
+
const isClosed =
|
|
11239
|
+
ancestor.tagName === "DETAILS"
|
|
11240
|
+
? !ancestor.open
|
|
11241
|
+
: e.newState === "closed";
|
|
11242
|
+
if (isClosed) {
|
|
11243
|
+
ancestorClosedCount++;
|
|
11244
|
+
update(
|
|
11245
|
+
{
|
|
11246
|
+
left: 0,
|
|
11247
|
+
top: 0,
|
|
11248
|
+
right: 0,
|
|
11249
|
+
bottom: 0,
|
|
11250
|
+
width: 0,
|
|
11251
|
+
height: 0,
|
|
11252
|
+
visibilityRatio: 0,
|
|
11253
|
+
},
|
|
11254
|
+
{ event: e, width: 0, height: 0, ancestorClosed: true },
|
|
11255
|
+
);
|
|
11256
|
+
} else {
|
|
11257
|
+
if (ancestorClosedCount > 0) {
|
|
11258
|
+
ancestorClosedCount--;
|
|
11259
|
+
}
|
|
11260
|
+
if (ancestorClosedCount === 0) {
|
|
11261
|
+
check(e);
|
|
11262
|
+
}
|
|
11263
|
+
}
|
|
11264
|
+
};
|
|
11265
|
+
ancestor.addEventListener("toggle", onToggle);
|
|
11266
|
+
addTeardown(() => {
|
|
11267
|
+
ancestor.removeEventListener("toggle", onToggle);
|
|
11268
|
+
});
|
|
11269
|
+
}
|
|
11270
|
+
current = current.parentElement;
|
|
11271
|
+
}
|
|
11272
|
+
}
|
|
10553
11273
|
}
|
|
10554
11274
|
|
|
10555
11275
|
return {
|
|
@@ -10611,6 +11331,7 @@ const pickPositionRelativeTo = (
|
|
|
10611
11331
|
alignToViewportEdgeWhenAnchorNearEdge = 0,
|
|
10612
11332
|
minLeft = 0,
|
|
10613
11333
|
spacing = 0,
|
|
11334
|
+
alignToAnchorBox = "border-box",
|
|
10614
11335
|
viewportSpacing = 0,
|
|
10615
11336
|
} = {},
|
|
10616
11337
|
) => {
|
|
@@ -10635,10 +11356,30 @@ const pickPositionRelativeTo = (
|
|
|
10635
11356
|
const anchorWidth = anchorRight - anchorLeft;
|
|
10636
11357
|
const anchorHeight = anchorBottom - anchorTop;
|
|
10637
11358
|
|
|
10638
|
-
|
|
10639
|
-
|
|
10640
|
-
|
|
10641
|
-
|
|
11359
|
+
// alignToAnchorBox controls whether the element aligns to the anchor's border-box (outer edge)
|
|
11360
|
+
// or content-box (inner content area, ignoring padding and border).
|
|
11361
|
+
// content-box lets the arrow point into the content area instead of the outer edge.
|
|
11362
|
+
// Insets are directional: top/bottom for Y-axis, left/right for X-axis.
|
|
11363
|
+
// When positioning above, only the top inset applies (content-box top edge).
|
|
11364
|
+
// When positioning below, only the bottom inset applies (content-box bottom edge).
|
|
11365
|
+
let insetTop = 0;
|
|
11366
|
+
let insetBottom = 0;
|
|
11367
|
+
let insetLeft = 0;
|
|
11368
|
+
let insetRight = 0;
|
|
11369
|
+
if (alignToAnchorBox === "content-box") {
|
|
11370
|
+
const anchorBorderSizes = getBorderSizes(anchor);
|
|
11371
|
+
const anchorPaddingSizes = getPaddingSizes(anchor);
|
|
11372
|
+
insetTop = anchorBorderSizes.top + anchorPaddingSizes.top;
|
|
11373
|
+
insetBottom = anchorBorderSizes.bottom + anchorPaddingSizes.bottom;
|
|
11374
|
+
insetLeft = anchorBorderSizes.left + anchorPaddingSizes.left;
|
|
11375
|
+
insetRight = anchorBorderSizes.right + anchorPaddingSizes.right;
|
|
11376
|
+
}
|
|
11377
|
+
const spaceAbove = anchorTop + insetTop;
|
|
11378
|
+
const spaceBelow = viewportHeight - anchorBottom + insetBottom;
|
|
11379
|
+
const effectiveAnchorLeft = anchorLeft + insetLeft;
|
|
11380
|
+
const effectiveAnchorRight = anchorRight - insetRight;
|
|
11381
|
+
const spaceLeft = anchorLeft + insetLeft;
|
|
11382
|
+
const spaceRight = viewportWidth - anchorRight + insetRight;
|
|
10642
11383
|
|
|
10643
11384
|
// Resolve active X and Y, and whether each is fixed (no flip fallback)
|
|
10644
11385
|
let activeX;
|
|
@@ -10712,7 +11453,11 @@ const pickPositionRelativeTo = (
|
|
|
10712
11453
|
if (currentFitsEnough) {
|
|
10713
11454
|
finalY = activeY;
|
|
10714
11455
|
} else {
|
|
10715
|
-
|
|
11456
|
+
// Only flip if the opposite side has more space — avoids oscillation
|
|
11457
|
+
// when neither side has enough room (both fail the ratio).
|
|
11458
|
+
const opposite = oppositeY[activeY];
|
|
11459
|
+
const oppositeHasMoreSpace = spaceFor(opposite) > spaceFor(activeY);
|
|
11460
|
+
finalY = oppositeHasMoreSpace ? opposite : activeY;
|
|
10716
11461
|
}
|
|
10717
11462
|
}
|
|
10718
11463
|
}
|
|
@@ -10775,44 +11520,49 @@ const pickPositionRelativeTo = (
|
|
|
10775
11520
|
let elementPositionLeft;
|
|
10776
11521
|
{
|
|
10777
11522
|
if (finalX === "to-the-left") {
|
|
10778
|
-
elementPositionLeft =
|
|
11523
|
+
elementPositionLeft = effectiveAnchorLeft - elementWidth - spacing;
|
|
10779
11524
|
} else if (finalX === "left-aligned") {
|
|
10780
|
-
elementPositionLeft =
|
|
11525
|
+
elementPositionLeft = effectiveAnchorLeft;
|
|
10781
11526
|
} else if (finalX === "center") {
|
|
10782
11527
|
// Complex logic handles wide anchors and viewport-edge snapping
|
|
10783
11528
|
const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
|
|
10784
11529
|
if (anchorIsWiderThanViewport) {
|
|
10785
|
-
const anchorLeftIsVisible =
|
|
10786
|
-
const anchorRightIsVisible =
|
|
11530
|
+
const anchorLeftIsVisible = effectiveAnchorLeft >= 0;
|
|
11531
|
+
const anchorRightIsVisible = effectiveAnchorRight <= viewportWidth;
|
|
10787
11532
|
if (!anchorLeftIsVisible && anchorRightIsVisible) {
|
|
10788
11533
|
const viewportCenter = viewportWidth / 2;
|
|
10789
|
-
const distanceFromRightEdge = viewportWidth -
|
|
11534
|
+
const distanceFromRightEdge = viewportWidth - effectiveAnchorRight;
|
|
10790
11535
|
elementPositionLeft =
|
|
10791
11536
|
viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
10792
11537
|
} else if (anchorLeftIsVisible && !anchorRightIsVisible) {
|
|
10793
11538
|
const viewportCenter = viewportWidth / 2;
|
|
10794
|
-
const distanceFromLeftEdge = -
|
|
11539
|
+
const distanceFromLeftEdge = -effectiveAnchorLeft;
|
|
10795
11540
|
elementPositionLeft =
|
|
10796
11541
|
viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
10797
11542
|
} else {
|
|
10798
11543
|
elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
|
|
10799
11544
|
}
|
|
10800
11545
|
} else {
|
|
10801
|
-
elementPositionLeft =
|
|
11546
|
+
elementPositionLeft =
|
|
11547
|
+
effectiveAnchorLeft +
|
|
11548
|
+
(effectiveAnchorRight - effectiveAnchorLeft) / 2 -
|
|
11549
|
+
elementWidth / 2;
|
|
10802
11550
|
if (alignToViewportEdgeWhenAnchorNearEdge) {
|
|
10803
|
-
const
|
|
11551
|
+
const effectiveAnchorWidth =
|
|
11552
|
+
effectiveAnchorRight - effectiveAnchorLeft;
|
|
11553
|
+
const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
|
|
10804
11554
|
const anchorIsNearLeftEdge =
|
|
10805
|
-
|
|
11555
|
+
effectiveAnchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
|
|
10806
11556
|
if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
|
|
10807
11557
|
elementPositionLeft = minLeft;
|
|
10808
11558
|
}
|
|
10809
11559
|
}
|
|
10810
11560
|
}
|
|
10811
11561
|
} else if (finalX === "right-aligned") {
|
|
10812
|
-
elementPositionLeft =
|
|
11562
|
+
elementPositionLeft = effectiveAnchorRight - elementWidth;
|
|
10813
11563
|
} else {
|
|
10814
11564
|
// "to-the-right"
|
|
10815
|
-
elementPositionLeft =
|
|
11565
|
+
elementPositionLeft = effectiveAnchorRight + spacing;
|
|
10816
11566
|
}
|
|
10817
11567
|
// Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
|
|
10818
11568
|
if (elementPositionLeft < viewportSpacing) {
|
|
@@ -10829,8 +11579,8 @@ const pickPositionRelativeTo = (
|
|
|
10829
11579
|
let elementPositionTop;
|
|
10830
11580
|
{
|
|
10831
11581
|
if (finalY === "above") {
|
|
10832
|
-
// top is always anchorTop - elementHeight - spacing — max-height truncates if needed.
|
|
10833
|
-
const idealTop = anchorTop - elementHeight - spacing;
|
|
11582
|
+
// top is always anchorTop + insetTop - elementHeight - spacing — max-height truncates if needed.
|
|
11583
|
+
const idealTop = anchorTop + insetTop - elementHeight - spacing;
|
|
10834
11584
|
elementPositionTop =
|
|
10835
11585
|
idealTop < viewportSpacing ? viewportSpacing : idealTop;
|
|
10836
11586
|
} else if (finalY === "above-overlap") {
|
|
@@ -10845,9 +11595,9 @@ const pickPositionRelativeTo = (
|
|
|
10845
11595
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10846
11596
|
} else {
|
|
10847
11597
|
// "below"
|
|
10848
|
-
// top is always anchorBottom + spacing — max-height (via --space-available) truncates
|
|
11598
|
+
// top is always anchorBottom - insetBottom + spacing — max-height (via --space-available) truncates
|
|
10849
11599
|
// the element height so it doesn't overflow the viewport bottom.
|
|
10850
|
-
const idealTop = anchorBottom + spacing;
|
|
11600
|
+
const idealTop = anchorBottom - insetBottom + spacing;
|
|
10851
11601
|
elementPositionTop =
|
|
10852
11602
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10853
11603
|
}
|
|
@@ -13898,6 +14648,144 @@ const getMaxWidth = (
|
|
|
13898
14648
|
return maxWidth;
|
|
13899
14649
|
};
|
|
13900
14650
|
|
|
14651
|
+
/**
|
|
14652
|
+
* Measures the width of the longest rendered visual line inside an element.
|
|
14653
|
+
*
|
|
14654
|
+
* Useful for solving the CSS "shrinkwrap" problem: when multi-line text sits
|
|
14655
|
+
* inside a `max-width` container, CSS expands the element to fill all
|
|
14656
|
+
* available space, leaving trailing whitespace to the right of the text.
|
|
14657
|
+
* Setting an explicit width equal to the longest line eliminates that gap.
|
|
14658
|
+
* See shrinkwrap_demo.html for a visual explanation.
|
|
14659
|
+
*
|
|
14660
|
+
* Returns `null` when all content fits on a single visual line (nothing to
|
|
14661
|
+
* optimize). Returns the pixel width of the widest line when text wraps to
|
|
14662
|
+
* two or more lines.
|
|
14663
|
+
*
|
|
14664
|
+
* ## Implementation note — bounding extent, not sum of widths
|
|
14665
|
+
*
|
|
14666
|
+
* `range.getClientRects()` returns one rect per layout box intersecting the
|
|
14667
|
+
* range. Nested elements (e.g. `<span><span>text</span></span>`) produce
|
|
14668
|
+
* multiple overlapping rects for the exact same pixels on the same line.
|
|
14669
|
+
* Summing their `width` values therefore over-counts the true line width.
|
|
14670
|
+
*
|
|
14671
|
+
* Instead we compute the bounding extent per line: track the minimum `left`
|
|
14672
|
+
* and maximum `right` across all rects sharing the same rounded `top`, then
|
|
14673
|
+
* use `right - left` as the line width. This is correct regardless of nesting
|
|
14674
|
+
* depth and works well for regular inline text content.
|
|
14675
|
+
*
|
|
14676
|
+
* Limitation: rects are grouped by `Math.round(r.top)`, so elements on the
|
|
14677
|
+
* same visual line but with slightly different baselines (e.g. an icon taller
|
|
14678
|
+
* than surrounding text) could be counted as separate lines. This is unlikely
|
|
14679
|
+
* to matter in practice for normal text rendering.
|
|
14680
|
+
*
|
|
14681
|
+
* Limitation: `range.getClientRects()` returns rects for text nodes and inline
|
|
14682
|
+
* boxes as laid out in the flow, ignoring any `overflow: hidden` or `max-width`
|
|
14683
|
+
* clipping applied to ancestor elements. If child elements clip their own
|
|
14684
|
+
* content (e.g. badges with `overflow: hidden` and `max-width`), the rects
|
|
14685
|
+
* will reflect the unclipped text size, producing a width larger than what is
|
|
14686
|
+
* visually rendered. In that case prefer `measureWidestChildRow`, which uses
|
|
14687
|
+
* each child's own `getBoundingClientRect()` and therefore respects clipping.
|
|
14688
|
+
*
|
|
14689
|
+
* @param {Element} el - The element whose text content should be measured.
|
|
14690
|
+
* @returns {number|null} Width in pixels of the longest visual line,
|
|
14691
|
+
* or `null` if there is only one visual line.
|
|
14692
|
+
*/
|
|
14693
|
+
const measureLongestVisualLineWidth = (el) => {
|
|
14694
|
+
const range = document.createRange();
|
|
14695
|
+
range.selectNodeContents(el);
|
|
14696
|
+
|
|
14697
|
+
const lineBoundsByTop = new Map();
|
|
14698
|
+
for (const r of range.getClientRects()) {
|
|
14699
|
+
if (r.width === 0) {
|
|
14700
|
+
continue;
|
|
14701
|
+
}
|
|
14702
|
+
const top = Math.round(r.top);
|
|
14703
|
+
const existing = lineBoundsByTop.get(top);
|
|
14704
|
+
if (existing === undefined) {
|
|
14705
|
+
lineBoundsByTop.set(top, { left: r.left, right: r.right });
|
|
14706
|
+
} else {
|
|
14707
|
+
if (r.left < existing.left) {
|
|
14708
|
+
existing.left = r.left;
|
|
14709
|
+
}
|
|
14710
|
+
if (r.right > existing.right) {
|
|
14711
|
+
existing.right = r.right;
|
|
14712
|
+
}
|
|
14713
|
+
}
|
|
14714
|
+
}
|
|
14715
|
+
|
|
14716
|
+
if (lineBoundsByTop.size <= 1) {
|
|
14717
|
+
return null;
|
|
14718
|
+
}
|
|
14719
|
+
|
|
14720
|
+
let longestLineWidth = 0;
|
|
14721
|
+
for (const { left, right } of lineBoundsByTop.values()) {
|
|
14722
|
+
const w = right - left;
|
|
14723
|
+
if (w > longestLineWidth) {
|
|
14724
|
+
longestLineWidth = w;
|
|
14725
|
+
}
|
|
14726
|
+
}
|
|
14727
|
+
return longestLineWidth;
|
|
14728
|
+
};
|
|
14729
|
+
|
|
14730
|
+
// Measures the width of the widest row of direct children.
|
|
14731
|
+
// Uses children's bounding rects (which respect overflow:hidden / max-width)
|
|
14732
|
+
// rather than Range.getClientRects() which sees through clipping boundaries.
|
|
14733
|
+
// Returns null when all children fit on a single row (nothing to optimize).
|
|
14734
|
+
const measureWidestChildRow = (el) => {
|
|
14735
|
+
const children = Array.from(el.children);
|
|
14736
|
+
if (children.length === 0) {
|
|
14737
|
+
return null;
|
|
14738
|
+
}
|
|
14739
|
+
|
|
14740
|
+
const containerStyle = getComputedStyle(el);
|
|
14741
|
+
const paddingLeft = parseFloat(containerStyle.paddingLeft);
|
|
14742
|
+
const paddingRight = parseFloat(containerStyle.paddingRight);
|
|
14743
|
+
const borderLeft = parseFloat(containerStyle.borderLeftWidth);
|
|
14744
|
+
const borderRight = parseFloat(containerStyle.borderRightWidth);
|
|
14745
|
+
|
|
14746
|
+
// Group children by row using their top position
|
|
14747
|
+
const rowsByTop = new Map();
|
|
14748
|
+
for (const child of children) {
|
|
14749
|
+
const rect = child.getBoundingClientRect();
|
|
14750
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
14751
|
+
continue;
|
|
14752
|
+
}
|
|
14753
|
+
const top = Math.round(rect.top);
|
|
14754
|
+
const existing = rowsByTop.get(top);
|
|
14755
|
+
if (existing === undefined) {
|
|
14756
|
+
rowsByTop.set(top, { left: rect.left, right: rect.right });
|
|
14757
|
+
} else {
|
|
14758
|
+
if (rect.left < existing.left) {
|
|
14759
|
+
existing.left = rect.left;
|
|
14760
|
+
}
|
|
14761
|
+
if (rect.right > existing.right) {
|
|
14762
|
+
existing.right = rect.right;
|
|
14763
|
+
}
|
|
14764
|
+
}
|
|
14765
|
+
}
|
|
14766
|
+
|
|
14767
|
+
if (rowsByTop.size <= 1) {
|
|
14768
|
+
return null;
|
|
14769
|
+
}
|
|
14770
|
+
|
|
14771
|
+
let widestRowWidth = 0;
|
|
14772
|
+
for (const { left, right } of rowsByTop.values()) {
|
|
14773
|
+
const rowWidth = right - left;
|
|
14774
|
+
if (rowWidth > widestRowWidth) {
|
|
14775
|
+
widestRowWidth = rowWidth;
|
|
14776
|
+
}
|
|
14777
|
+
}
|
|
14778
|
+
|
|
14779
|
+
// Convert from absolute pixel width to the container's content-box width
|
|
14780
|
+
// so that setting el.style.width = result + "px" works correctly.
|
|
14781
|
+
if (containerStyle.boxSizing === "border-box") {
|
|
14782
|
+
return (
|
|
14783
|
+
widestRowWidth + paddingLeft + paddingRight + borderLeft + borderRight
|
|
14784
|
+
);
|
|
14785
|
+
}
|
|
14786
|
+
return widestRowWidth;
|
|
14787
|
+
};
|
|
14788
|
+
|
|
13901
14789
|
const useAvailableHeight = (elementRef) => {
|
|
13902
14790
|
const [availableHeight, availableHeightSetter] = useState(-1);
|
|
13903
14791
|
|
|
@@ -14014,4 +14902,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
|
|
|
14014
14902
|
};
|
|
14015
14903
|
};
|
|
14016
14904
|
|
|
14017
|
-
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles,
|
|
14905
|
+
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, captureScrollState, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, formatEventSideEffect, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
|