@jsenv/dom 0.11.3 → 0.12.1
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 +923 -148
- package/package.json +1 -1
package/dist/jsenv_dom.js
CHANGED
|
@@ -128,17 +128,37 @@ const getElementSignature = (element) => {
|
|
|
128
128
|
}
|
|
129
129
|
return `input[type="${type}"]`;
|
|
130
130
|
}
|
|
131
|
+
if (tagName === "form") {
|
|
132
|
+
const name = element.getAttribute("name");
|
|
133
|
+
if (name) {
|
|
134
|
+
return `form[name="${name}"]`;
|
|
135
|
+
}
|
|
136
|
+
return "form";
|
|
137
|
+
}
|
|
131
138
|
if (element === document.body) {
|
|
132
|
-
return "
|
|
139
|
+
return "document.body";
|
|
133
140
|
}
|
|
134
141
|
if (element === document.documentElement) {
|
|
135
|
-
return "
|
|
142
|
+
return "document.html";
|
|
136
143
|
}
|
|
137
144
|
const elementId = element.id;
|
|
138
|
-
const className = element.className;
|
|
139
145
|
if (elementId && !looksLikeGeneratedId(elementId)) {
|
|
140
146
|
return `${tagName}#${elementId}`;
|
|
141
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;
|
|
142
162
|
if (className) {
|
|
143
163
|
return `${tagName}.${className.split(" ").join(".")}`;
|
|
144
164
|
}
|
|
@@ -188,7 +208,8 @@ const dispatchInternalCustomEvent = (
|
|
|
188
208
|
customEventDetail,
|
|
189
209
|
) => {
|
|
190
210
|
const customEvent = new CustomEvent(customEventName, {
|
|
191
|
-
detail: customEventDetail,
|
|
211
|
+
detail: resolveEventDetail(customEventDetail),
|
|
212
|
+
cancelable: true,
|
|
192
213
|
});
|
|
193
214
|
return el.dispatchEvent(customEvent);
|
|
194
215
|
};
|
|
@@ -230,9 +251,13 @@ const resolveEventDetail = (customEventDetail) => {
|
|
|
230
251
|
if (!isWrappedCustomEvent) {
|
|
231
252
|
return { ...rest, event };
|
|
232
253
|
}
|
|
254
|
+
// Keep `event` as the direct parent so callers see the immediate facade.
|
|
255
|
+
// Build eventChain as [root, ...grandparents] — oldest first, excluding `event`.
|
|
233
256
|
const previousChain = event.detail.eventChain;
|
|
234
|
-
const eventChain = previousChain
|
|
235
|
-
|
|
257
|
+
const eventChain = previousChain
|
|
258
|
+
? [...previousChain, event.detail.event]
|
|
259
|
+
: [event.detail.event];
|
|
260
|
+
return { ...rest, event, eventChain };
|
|
236
261
|
};
|
|
237
262
|
|
|
238
263
|
/**
|
|
@@ -242,26 +267,43 @@ const resolveEventDetail = (customEventDetail) => {
|
|
|
242
267
|
* initiator (event.detail.event) → ...intermediates (event.detail.eventChain)... → event
|
|
243
268
|
*
|
|
244
269
|
* Examples:
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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")
|
|
247
274
|
*/
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
return
|
|
275
|
+
const findEvent = (event, predicate) => {
|
|
276
|
+
if (!event) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const match = resolveEventPredicate(predicate);
|
|
280
|
+
if (match(event)) {
|
|
281
|
+
return event;
|
|
251
282
|
}
|
|
252
283
|
if (event.detail?.eventChain) {
|
|
253
284
|
for (const chainedEvent of event.detail.eventChain) {
|
|
254
|
-
if (
|
|
255
|
-
return
|
|
285
|
+
if (match(chainedEvent)) {
|
|
286
|
+
return chainedEvent;
|
|
256
287
|
}
|
|
257
288
|
}
|
|
258
289
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
const initiator = event.detail?.event;
|
|
291
|
+
if (initiator) {
|
|
292
|
+
if (match(initiator)) {
|
|
293
|
+
return initiator;
|
|
262
294
|
}
|
|
263
295
|
}
|
|
264
|
-
return
|
|
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;
|
|
265
307
|
};
|
|
266
308
|
|
|
267
309
|
/**
|
|
@@ -272,14 +314,16 @@ const eventInvolves = (event, predicate) => {
|
|
|
272
314
|
const formatEventSideEffect = (e, sideEffect) => {
|
|
273
315
|
const parts = [];
|
|
274
316
|
if (e.detail?.event !== undefined) {
|
|
275
|
-
const
|
|
317
|
+
const chain = e.detail.eventChain;
|
|
318
|
+
const initiator = chain ? chain[0] : e.detail.event;
|
|
276
319
|
parts.push(
|
|
277
320
|
`"${initiator.type}" on ${getElementSignature(initiator.target)}`,
|
|
278
321
|
);
|
|
279
|
-
if (
|
|
280
|
-
for (const chainedEvent of
|
|
322
|
+
if (chain) {
|
|
323
|
+
for (const chainedEvent of chain.slice(1)) {
|
|
281
324
|
parts.push(chainedEvent.type);
|
|
282
325
|
}
|
|
326
|
+
parts.push(e.detail.event.type);
|
|
283
327
|
}
|
|
284
328
|
parts.push(e.type);
|
|
285
329
|
} else {
|
|
@@ -319,7 +363,8 @@ const createEventGroupLogger = () => {
|
|
|
319
363
|
return;
|
|
320
364
|
}
|
|
321
365
|
const e = eOrMessage;
|
|
322
|
-
const
|
|
366
|
+
const chain = e.detail?.eventChain;
|
|
367
|
+
const initiator = chain ? chain[0] : (e.detail?.event ?? e);
|
|
323
368
|
if (initiator !== currentInitiator) {
|
|
324
369
|
if (currentInitiator !== null) {
|
|
325
370
|
clearTimeout(closeGroupTimeout);
|
|
@@ -340,10 +385,15 @@ const createEventGroupLogger = () => {
|
|
|
340
385
|
|
|
341
386
|
const formatSideEffectLine = (e, sideEffect) => {
|
|
342
387
|
const parts = [];
|
|
343
|
-
|
|
344
|
-
|
|
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)) {
|
|
345
392
|
parts.push(chainedEvent.type);
|
|
346
393
|
}
|
|
394
|
+
if (e.detail?.event) {
|
|
395
|
+
parts.push(e.detail.event.type);
|
|
396
|
+
}
|
|
347
397
|
}
|
|
348
398
|
parts.push(sideEffect);
|
|
349
399
|
return parts.join(" -> ");
|
|
@@ -4086,10 +4136,20 @@ const addActiveElementEffect = (callback) => {
|
|
|
4086
4136
|
return remove;
|
|
4087
4137
|
};
|
|
4088
4138
|
|
|
4089
|
-
|
|
4090
|
-
|
|
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;
|
|
4091
4151
|
};
|
|
4092
|
-
const getFocusVisibilityInfo = (node) => {
|
|
4152
|
+
const getFocusVisibilityInfo = (node, { excludeAriaHidden } = {}) => {
|
|
4093
4153
|
if (isDocumentElement(node)) {
|
|
4094
4154
|
return { visible: true, reason: "is document" };
|
|
4095
4155
|
}
|
|
@@ -4107,6 +4167,12 @@ const getFocusVisibilityInfo = (node) => {
|
|
|
4107
4167
|
if (isDocumentElement(nodeOrAncestor)) {
|
|
4108
4168
|
break;
|
|
4109
4169
|
}
|
|
4170
|
+
if (
|
|
4171
|
+
excludeAriaHidden &&
|
|
4172
|
+
nodeOrAncestor.getAttribute("aria-hidden") === "true"
|
|
4173
|
+
) {
|
|
4174
|
+
return { visible: false, reason: "inside aria-hidden element" };
|
|
4175
|
+
}
|
|
4110
4176
|
if (getStyle(nodeOrAncestor, "display") === "none") {
|
|
4111
4177
|
return { visible: false, reason: "ancestor uses display: none" };
|
|
4112
4178
|
}
|
|
@@ -4163,9 +4229,17 @@ const getVisuallyVisibleInfo = (
|
|
|
4163
4229
|
return { visible: false, reason: "clipped with clip property" };
|
|
4164
4230
|
}
|
|
4165
4231
|
|
|
4232
|
+
if (node.hasAttribute("navi-visually-hidden")) {
|
|
4233
|
+
return { visible: false, reason: "has navi-visually-hidden attribute" };
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4166
4236
|
const clipPathStyle = getStyle(node, "clip-path");
|
|
4167
|
-
if (clipPathStyle
|
|
4168
|
-
|
|
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
|
+
}
|
|
4169
4243
|
}
|
|
4170
4244
|
|
|
4171
4245
|
// Check if positioned off-screen (unless option says to count as visible)
|
|
@@ -4201,47 +4275,64 @@ const getFirstVisuallyVisibleAncestor = (node, options = {}) => {
|
|
|
4201
4275
|
return null;
|
|
4202
4276
|
};
|
|
4203
4277
|
|
|
4204
|
-
|
|
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 } = {}) => {
|
|
4205
4290
|
// only element node can be focused, document, textNodes etc cannot
|
|
4206
4291
|
if (node.nodeType !== 1) {
|
|
4207
4292
|
return false;
|
|
4208
4293
|
}
|
|
4294
|
+
if (node.hasAttribute("navi-focus-delegate")) {
|
|
4295
|
+
return false;
|
|
4296
|
+
}
|
|
4209
4297
|
if (!canInteract(node)) {
|
|
4210
4298
|
return false;
|
|
4211
4299
|
}
|
|
4300
|
+
const canFocus = (node) =>
|
|
4301
|
+
elementIsVisibleForFocus(node, { excludeAriaHidden });
|
|
4302
|
+
|
|
4212
4303
|
const nodeName = node.nodeName.toLowerCase();
|
|
4213
4304
|
if (nodeName === "input") {
|
|
4214
4305
|
if (node.type === "hidden") {
|
|
4215
4306
|
return false;
|
|
4216
4307
|
}
|
|
4217
|
-
return
|
|
4308
|
+
return canFocus(node);
|
|
4218
4309
|
}
|
|
4219
4310
|
if (
|
|
4220
4311
|
["button", "select", "datalist", "iframe", "textarea"].indexOf(nodeName) >
|
|
4221
4312
|
-1
|
|
4222
4313
|
) {
|
|
4223
|
-
return
|
|
4314
|
+
return canFocus(node);
|
|
4224
4315
|
}
|
|
4225
4316
|
if (["a", "area"].indexOf(nodeName) > -1) {
|
|
4226
4317
|
if (node.hasAttribute("href") === false) {
|
|
4227
4318
|
return false;
|
|
4228
4319
|
}
|
|
4229
|
-
return
|
|
4320
|
+
return canFocus(node);
|
|
4230
4321
|
}
|
|
4231
4322
|
if (["audio", "video"].indexOf(nodeName) > -1) {
|
|
4232
4323
|
if (node.hasAttribute("controls") === false) {
|
|
4233
4324
|
return false;
|
|
4234
4325
|
}
|
|
4235
|
-
return
|
|
4326
|
+
return canFocus(node);
|
|
4236
4327
|
}
|
|
4237
4328
|
if (nodeName === "summary") {
|
|
4238
|
-
return
|
|
4329
|
+
return canFocus(node);
|
|
4239
4330
|
}
|
|
4240
4331
|
if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
|
|
4241
|
-
return
|
|
4332
|
+
return canFocus(node);
|
|
4242
4333
|
}
|
|
4243
4334
|
if (node.hasAttribute("draggable")) {
|
|
4244
|
-
return
|
|
4335
|
+
return canFocus(node);
|
|
4245
4336
|
}
|
|
4246
4337
|
return false;
|
|
4247
4338
|
};
|
|
@@ -4257,73 +4348,327 @@ const canInteract = (element) => {
|
|
|
4257
4348
|
return true;
|
|
4258
4349
|
};
|
|
4259
4350
|
|
|
4260
|
-
|
|
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
|
+
|
|
4378
|
+
const findFocusable = (element, { exclude } = {}) => {
|
|
4261
4379
|
const associatedElements = getAssociatedElements(element);
|
|
4262
4380
|
if (associatedElements) {
|
|
4263
4381
|
for (const associatedElement of associatedElements) {
|
|
4264
|
-
const focusable = findFocusable(associatedElement);
|
|
4382
|
+
const focusable = findFocusable(associatedElement, { exclude });
|
|
4265
4383
|
if (focusable) {
|
|
4266
4384
|
return focusable;
|
|
4267
4385
|
}
|
|
4268
4386
|
}
|
|
4269
4387
|
return null;
|
|
4270
4388
|
}
|
|
4271
|
-
|
|
4389
|
+
const isFocusable = (node) => {
|
|
4390
|
+
if (!elementIsFocusable(node)) {
|
|
4391
|
+
return false;
|
|
4392
|
+
}
|
|
4393
|
+
if (exclude && exclude(node)) {
|
|
4394
|
+
return false;
|
|
4395
|
+
}
|
|
4396
|
+
return true;
|
|
4397
|
+
};
|
|
4398
|
+
if (isFocusable(element)) {
|
|
4272
4399
|
return element;
|
|
4273
4400
|
}
|
|
4274
|
-
const focusableDescendant = findDescendant(element,
|
|
4401
|
+
const focusableDescendant = findDescendant(element, isFocusable);
|
|
4402
|
+
if (focusableDescendant) {
|
|
4403
|
+
// If the first focusable is an unchecked radio/checkbox, prefer the checked
|
|
4404
|
+
// sibling in the same group (mirrors native browser radio focus behavior
|
|
4405
|
+
// and gives focus to the selected item in a selectable list).
|
|
4406
|
+
const { tagName, type, name } = focusableDescendant;
|
|
4407
|
+
if (
|
|
4408
|
+
tagName === "INPUT" &&
|
|
4409
|
+
(type === "radio" || type === "checkbox") &&
|
|
4410
|
+
!focusableDescendant.checked &&
|
|
4411
|
+
name
|
|
4412
|
+
) {
|
|
4413
|
+
const groupContainer = focusableDescendant.form || document;
|
|
4414
|
+
const checkedInput = groupContainer.querySelector(
|
|
4415
|
+
`input[type="${type}"][name="${CSS.escape(name)}"]:checked`,
|
|
4416
|
+
);
|
|
4417
|
+
if (checkedInput) {
|
|
4418
|
+
return checkedInput;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4275
4422
|
return focusableDescendant;
|
|
4276
4423
|
};
|
|
4277
4424
|
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
const
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
if (INPUT_ALLOWED_KEYS.has(event.key)) {
|
|
4297
|
-
return true;
|
|
4298
|
-
}
|
|
4299
|
-
if (INPUT_ARROW_KEYS.has(event.key)) {
|
|
4300
|
-
return !INPUT_TYPES_WITH_ARROW_MEANING.has(target.type);
|
|
4301
|
-
}
|
|
4302
|
-
return false;
|
|
4303
|
-
}
|
|
4304
|
-
if (
|
|
4305
|
-
target.tagName === "TEXTAREA" ||
|
|
4306
|
-
target.contentEditable === "true" ||
|
|
4307
|
-
target.isContentEditable
|
|
4308
|
-
) {
|
|
4309
|
-
return TEXTAREA_ALLOWED_KEYS.has(event.key);
|
|
4310
|
-
}
|
|
4311
|
-
// Don't handle shortcuts when select dropdown is open
|
|
4312
|
-
if (target.tagName === "SELECT") {
|
|
4313
|
-
return false;
|
|
4425
|
+
/**
|
|
4426
|
+
* Returns the browser's default action for a keyboard event on its target element.
|
|
4427
|
+
*
|
|
4428
|
+
* Possible return values:
|
|
4429
|
+
* - `"activate"` — Space/Enter triggers the element's primary action (button click, checkbox toggle, picker open…)
|
|
4430
|
+
* - `"form_submit"` — Enter submits the enclosing form (single-line inputs)
|
|
4431
|
+
* - `"dismiss"` — Escape closes a dialog, clears a search field, collapses a dropdown
|
|
4432
|
+
* - `"focus_nav"` — key moves focus (Tab, arrow keys in a radio/checkbox group)
|
|
4433
|
+
* - `"value_change"` — key increments/decrements the field value (range, number, date…)
|
|
4434
|
+
* - `"cursor_move"` — key moves the text cursor within the field
|
|
4435
|
+
* - `"type"` — key produces or deletes text content
|
|
4436
|
+
* - `"scroll"` — key scrolls the page or a scrollable container
|
|
4437
|
+
* - `""` — no meaningful browser default; safe to intercept freely
|
|
4438
|
+
*/
|
|
4439
|
+
const normalizeKeyboardKey = (rawKey) => {
|
|
4440
|
+
// The browser sends " " for the Space bar; map it to the friendly name "space"
|
|
4441
|
+
if (rawKey === " ") {
|
|
4442
|
+
return "space";
|
|
4314
4443
|
}
|
|
4315
|
-
|
|
4444
|
+
return rawKey.toLowerCase();
|
|
4445
|
+
};
|
|
4446
|
+
|
|
4447
|
+
const getKeyboardEventDefaultAction = (keyboardEvent) => {
|
|
4448
|
+
const target = keyboardEvent.target;
|
|
4449
|
+
const key = normalizeKeyboardKey(keyboardEvent.key);
|
|
4450
|
+
|
|
4451
|
+
// Nothing special occurs when the target or an ancestor is disabled/inert
|
|
4316
4452
|
if (
|
|
4317
4453
|
target.disabled ||
|
|
4318
4454
|
target.closest("[disabled]") ||
|
|
4319
4455
|
target.inert ||
|
|
4320
4456
|
target.closest("[inert]")
|
|
4321
4457
|
) {
|
|
4458
|
+
return "";
|
|
4459
|
+
}
|
|
4460
|
+
for (const { test, keys, fallback } of DEFAULT_BEHAVIORS) {
|
|
4461
|
+
if (!test(target)) {
|
|
4462
|
+
continue;
|
|
4463
|
+
}
|
|
4464
|
+
if (Object.hasOwn(keys, key)) {
|
|
4465
|
+
const value = keys[key];
|
|
4466
|
+
const defaultActionForKey =
|
|
4467
|
+
typeof value === "function" ? value(keyboardEvent) : value;
|
|
4468
|
+
if (defaultActionForKey !== undefined) {
|
|
4469
|
+
return defaultActionForKey;
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
if (fallback === undefined) {
|
|
4473
|
+
// This entry only handles specific keys — keep looking for other entries
|
|
4474
|
+
continue;
|
|
4475
|
+
}
|
|
4476
|
+
const defaultAction =
|
|
4477
|
+
typeof fallback === "function" ? fallback(keyboardEvent) : fallback;
|
|
4478
|
+
if (defaultAction !== undefined) {
|
|
4479
|
+
return defaultAction;
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
return "";
|
|
4483
|
+
};
|
|
4484
|
+
|
|
4485
|
+
const isTypingIntent = (e) => {
|
|
4486
|
+
// Modifier keys used for shortcuts: skip
|
|
4487
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4322
4488
|
return false;
|
|
4323
4489
|
}
|
|
4324
|
-
|
|
4490
|
+
const key = normalizeKeyboardKey(e.key);
|
|
4491
|
+
// Single printable character — the user is typing
|
|
4492
|
+
if (e.key.length === 1) {
|
|
4493
|
+
return true;
|
|
4494
|
+
}
|
|
4495
|
+
// Editing keys that would modify the text
|
|
4496
|
+
if (key === "backspace" || key === "delete") {
|
|
4497
|
+
return true;
|
|
4498
|
+
}
|
|
4499
|
+
return false;
|
|
4325
4500
|
};
|
|
4326
4501
|
|
|
4502
|
+
const DEFAULT_BEHAVIORS = [
|
|
4503
|
+
{
|
|
4504
|
+
test: () => true,
|
|
4505
|
+
keys: {
|
|
4506
|
+
// Tab moves focus on any element
|
|
4507
|
+
tab: "focus_nav",
|
|
4508
|
+
},
|
|
4509
|
+
// no fallback: only claims Tab, other keys continue to next entries
|
|
4510
|
+
},
|
|
4511
|
+
{
|
|
4512
|
+
// Escape natively dismisses only <dialog> elements
|
|
4513
|
+
test: (el) => el.tagName === "DIALOG" || Boolean(el.closest("dialog")),
|
|
4514
|
+
keys: {
|
|
4515
|
+
escape: "dismiss",
|
|
4516
|
+
},
|
|
4517
|
+
},
|
|
4518
|
+
{
|
|
4519
|
+
test: (el) => el.matches("input[type='radio'], input[type='checkbox']"),
|
|
4520
|
+
keys: {
|
|
4521
|
+
space: "activate",
|
|
4522
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4523
|
+
arrowleft: "focus_nav",
|
|
4524
|
+
arrowright: "focus_nav",
|
|
4525
|
+
arrowup: "focus_nav",
|
|
4526
|
+
arrowdown: "focus_nav",
|
|
4527
|
+
},
|
|
4528
|
+
},
|
|
4529
|
+
{
|
|
4530
|
+
test: (el) =>
|
|
4531
|
+
el.matches(
|
|
4532
|
+
"input:not([type]), input[type='text'], input[type='search'], input[type='url'], input[type='email'], input[type='password'], input[type='tel']",
|
|
4533
|
+
),
|
|
4534
|
+
keys: {
|
|
4535
|
+
escape: (e) => {
|
|
4536
|
+
if (e.target.type === "search") {
|
|
4537
|
+
return e.target.value ? "clear" : "";
|
|
4538
|
+
}
|
|
4539
|
+
return "";
|
|
4540
|
+
},
|
|
4541
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4542
|
+
arrowleft: "cursor_move",
|
|
4543
|
+
arrowright: "cursor_move",
|
|
4544
|
+
arrowup: "cursor_move",
|
|
4545
|
+
arrowdown: "cursor_move",
|
|
4546
|
+
home: "cursor_move",
|
|
4547
|
+
end: "cursor_move",
|
|
4548
|
+
},
|
|
4549
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4550
|
+
},
|
|
4551
|
+
{
|
|
4552
|
+
test: (el) => el.matches("input[type='range']"),
|
|
4553
|
+
keys: {
|
|
4554
|
+
space: "scroll",
|
|
4555
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4556
|
+
arrowleft: "value_change",
|
|
4557
|
+
arrowright: "value_change",
|
|
4558
|
+
arrowup: "value_change",
|
|
4559
|
+
arrowdown: "value_change",
|
|
4560
|
+
home: "value_change",
|
|
4561
|
+
end: "value_change",
|
|
4562
|
+
pageup: "value_change",
|
|
4563
|
+
pagedown: "value_change",
|
|
4564
|
+
},
|
|
4565
|
+
},
|
|
4566
|
+
{
|
|
4567
|
+
test: (el) => el.matches("input[type='number']"),
|
|
4568
|
+
keys: {
|
|
4569
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4570
|
+
arrowleft: "cursor_move",
|
|
4571
|
+
arrowright: "cursor_move",
|
|
4572
|
+
arrowup: "value_change",
|
|
4573
|
+
arrowdown: "value_change",
|
|
4574
|
+
home: "cursor_move",
|
|
4575
|
+
end: "cursor_move",
|
|
4576
|
+
},
|
|
4577
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4578
|
+
},
|
|
4579
|
+
{
|
|
4580
|
+
test: (el) =>
|
|
4581
|
+
el.matches(
|
|
4582
|
+
"input[type='date'], input[type='time'], input[type='datetime-local'], input[type='month'], input[type='week']",
|
|
4583
|
+
),
|
|
4584
|
+
keys: {
|
|
4585
|
+
space: "activate",
|
|
4586
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4587
|
+
arrowleft: "value_change",
|
|
4588
|
+
arrowright: "value_change",
|
|
4589
|
+
arrowup: "value_change",
|
|
4590
|
+
arrowdown: "value_change",
|
|
4591
|
+
},
|
|
4592
|
+
},
|
|
4593
|
+
{
|
|
4594
|
+
// Color input: Space opens the color picker, Enter submits the form
|
|
4595
|
+
test: (el) => el.matches("input[type='color']"),
|
|
4596
|
+
keys: {
|
|
4597
|
+
space: "activate",
|
|
4598
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4599
|
+
},
|
|
4600
|
+
},
|
|
4601
|
+
{
|
|
4602
|
+
// File input: Space opens the picker, Enter submits the form
|
|
4603
|
+
test: (el) => el.matches("input[type='file']"),
|
|
4604
|
+
keys: {
|
|
4605
|
+
space: "activate",
|
|
4606
|
+
enter: (e) => (e.target.form ? "form_submit" : ""),
|
|
4607
|
+
},
|
|
4608
|
+
},
|
|
4609
|
+
{
|
|
4610
|
+
// Generic INPUT fallback for any remaining input types
|
|
4611
|
+
test: (el) => el.tagName === "INPUT",
|
|
4612
|
+
keys: {},
|
|
4613
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4614
|
+
},
|
|
4615
|
+
{
|
|
4616
|
+
test: (el) =>
|
|
4617
|
+
el.tagName === "TEXTAREA" ||
|
|
4618
|
+
el.contentEditable === "true" ||
|
|
4619
|
+
el.isContentEditable,
|
|
4620
|
+
keys: {
|
|
4621
|
+
enter: "type",
|
|
4622
|
+
arrowleft: "cursor_move",
|
|
4623
|
+
arrowright: "cursor_move",
|
|
4624
|
+
arrowup: "cursor_move",
|
|
4625
|
+
arrowdown: "cursor_move",
|
|
4626
|
+
home: "cursor_move",
|
|
4627
|
+
end: "cursor_move",
|
|
4628
|
+
},
|
|
4629
|
+
fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
|
|
4630
|
+
},
|
|
4631
|
+
{
|
|
4632
|
+
// Buttons and links: Space/Enter trigger the element's default action
|
|
4633
|
+
test: (el) =>
|
|
4634
|
+
el.tagName === "BUTTON" ||
|
|
4635
|
+
el.tagName === "A" ||
|
|
4636
|
+
el.getAttribute("role") === "button",
|
|
4637
|
+
keys: {
|
|
4638
|
+
space: "activate",
|
|
4639
|
+
enter: "activate",
|
|
4640
|
+
},
|
|
4641
|
+
},
|
|
4642
|
+
{
|
|
4643
|
+
// details/summary: Space/Enter toggle the disclosure widget
|
|
4644
|
+
test: (el) => el.tagName === "DETAILS" || el.tagName === "SUMMARY",
|
|
4645
|
+
keys: {
|
|
4646
|
+
space: "activate",
|
|
4647
|
+
enter: "activate",
|
|
4648
|
+
},
|
|
4649
|
+
},
|
|
4650
|
+
{
|
|
4651
|
+
// SELECT: don't intercept anything while the dropdown may be open
|
|
4652
|
+
test: (el) => el.tagName === "SELECT",
|
|
4653
|
+
keys: {},
|
|
4654
|
+
},
|
|
4655
|
+
{
|
|
4656
|
+
// Non-interactive elements: browser scrolls on Space and arrow keys
|
|
4657
|
+
test: () => true,
|
|
4658
|
+
keys: {
|
|
4659
|
+
space: "scroll",
|
|
4660
|
+
arrowup: "scroll",
|
|
4661
|
+
arrowdown: "scroll",
|
|
4662
|
+
arrowleft: "scroll",
|
|
4663
|
+
arrowright: "scroll",
|
|
4664
|
+
pageup: "scroll",
|
|
4665
|
+
pagedown: "scroll",
|
|
4666
|
+
home: "scroll",
|
|
4667
|
+
end: "scroll",
|
|
4668
|
+
},
|
|
4669
|
+
},
|
|
4670
|
+
];
|
|
4671
|
+
|
|
4327
4672
|
// WeakMap to store focus group metadata
|
|
4328
4673
|
const focusGroupRegistry = new WeakMap();
|
|
4329
4674
|
|
|
@@ -4365,12 +4710,50 @@ const markFocusNav = (event) => {
|
|
|
4365
4710
|
focusNavEventMarker.mark(event);
|
|
4366
4711
|
};
|
|
4367
4712
|
|
|
4713
|
+
/**
|
|
4714
|
+
* Performs arrow-key navigation within a focus group element.
|
|
4715
|
+
*
|
|
4716
|
+
* Called on every keydown event inside the group. Decides whether the pressed
|
|
4717
|
+
* key should move focus to another element, and if so, which one.
|
|
4718
|
+
*
|
|
4719
|
+
* @param {KeyboardEvent} event - The keydown event.
|
|
4720
|
+
* @param {Element} element - The focus-group root element.
|
|
4721
|
+
* @param {object} [options]
|
|
4722
|
+
* @param {string} [options.name] - Optional group name used for ancestor delegation.
|
|
4723
|
+
* @param {boolean} [options.excludeAriaHidden=true] - Skip elements hidden from the accessibility tree.
|
|
4724
|
+
* @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
|
|
4725
|
+
* "x" = left/right only, "y" = up/down only, "both" = all four arrows.
|
|
4726
|
+
* @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
|
|
4727
|
+
* Omit or pass undefined for no looping on either axis.
|
|
4728
|
+
* @param {string} [options.xSelector] - CSS selector that candidates must match
|
|
4729
|
+
* when navigating on the x axis. Omit to allow any focusable element.
|
|
4730
|
+
* @param {string} [options.ySelector] - CSS selector that candidates must match
|
|
4731
|
+
* when navigating on the y axis. Omit to allow any focusable element.
|
|
4732
|
+
* @returns {boolean} True if the event was handled (focus moved or default prevented).
|
|
4733
|
+
*/
|
|
4368
4734
|
const performArrowNavigation = (
|
|
4369
4735
|
event,
|
|
4370
4736
|
element,
|
|
4371
|
-
{
|
|
4737
|
+
{
|
|
4738
|
+
name,
|
|
4739
|
+
excludeAriaHidden,
|
|
4740
|
+
// Which axes are active: "x", "y", or "both" (default)
|
|
4741
|
+
direction = "both",
|
|
4742
|
+
// Which axes loop at boundaries: "x", "y", "both", or undefined (no looping)
|
|
4743
|
+
wrap,
|
|
4744
|
+
// CSS selector to restrict candidates on each axis
|
|
4745
|
+
xSelector,
|
|
4746
|
+
ySelector,
|
|
4747
|
+
} = {},
|
|
4372
4748
|
) => {
|
|
4373
|
-
|
|
4749
|
+
const defaultAction = getKeyboardEventDefaultAction(event);
|
|
4750
|
+
// A focus group takes over arrow-key navigation entirely, including cases
|
|
4751
|
+
// where the browser would otherwise scroll (e.g. arrow keys on a <button>).
|
|
4752
|
+
const canIntercept =
|
|
4753
|
+
defaultAction === "focus_nav" ||
|
|
4754
|
+
defaultAction === "scroll" ||
|
|
4755
|
+
!defaultAction;
|
|
4756
|
+
if (!canIntercept) {
|
|
4374
4757
|
return false;
|
|
4375
4758
|
}
|
|
4376
4759
|
const activeElement = document.activeElement;
|
|
@@ -4389,7 +4772,23 @@ const performArrowNavigation = (
|
|
|
4389
4772
|
// Grid navigation: we support only TABLE element for now
|
|
4390
4773
|
// A role="table" or an element with display: table could be used too but for now we need only TABLE support
|
|
4391
4774
|
if (element.tagName === "TABLE") {
|
|
4392
|
-
const
|
|
4775
|
+
const tablePredicate = (candidate) => {
|
|
4776
|
+
if (!candidate.matches) {
|
|
4777
|
+
return false;
|
|
4778
|
+
}
|
|
4779
|
+
if (candidate.getAttribute("navi-focusnav") === "ignore") {
|
|
4780
|
+
return false;
|
|
4781
|
+
}
|
|
4782
|
+
if (!elementIsFocusable(candidate, { excludeAriaHidden })) {
|
|
4783
|
+
return false;
|
|
4784
|
+
}
|
|
4785
|
+
return true;
|
|
4786
|
+
};
|
|
4787
|
+
const tableLoop = wrap === "both" || wrap === "x" || wrap === "y";
|
|
4788
|
+
const targetInGrid = getTargetInTableFocusGroup(event, element, {
|
|
4789
|
+
loop: tableLoop,
|
|
4790
|
+
predicate: tablePredicate,
|
|
4791
|
+
});
|
|
4393
4792
|
if (!targetInGrid) {
|
|
4394
4793
|
return false;
|
|
4395
4794
|
}
|
|
@@ -4397,12 +4796,67 @@ const performArrowNavigation = (
|
|
|
4397
4796
|
return true;
|
|
4398
4797
|
}
|
|
4399
4798
|
|
|
4799
|
+
// Linear navigation: detect which axis the pressed key belongs to.
|
|
4800
|
+
const isVerticalKey = event.key === "ArrowUp" || event.key === "ArrowDown";
|
|
4801
|
+
const isHorizontalKey =
|
|
4802
|
+
event.key === "ArrowLeft" || event.key === "ArrowRight";
|
|
4803
|
+
if (!isVerticalKey && !isHorizontalKey) {
|
|
4804
|
+
return false;
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
// Check whether this axis is enabled and resolve its loop + cssSelector.
|
|
4808
|
+
let axisDirection;
|
|
4809
|
+
let axisLoop;
|
|
4810
|
+
let axisCssSelector;
|
|
4811
|
+
if (isVerticalKey) {
|
|
4812
|
+
if (direction !== "both" && direction !== "y") {
|
|
4813
|
+
return false;
|
|
4814
|
+
}
|
|
4815
|
+
axisDirection = "vertical";
|
|
4816
|
+
axisLoop = wrap === "both" || wrap === "y";
|
|
4817
|
+
axisCssSelector = ySelector;
|
|
4818
|
+
} else {
|
|
4819
|
+
if (direction !== "both" && direction !== "x") {
|
|
4820
|
+
return false;
|
|
4821
|
+
}
|
|
4822
|
+
axisDirection = "horizontal";
|
|
4823
|
+
axisLoop = wrap === "both" || wrap === "x";
|
|
4824
|
+
axisCssSelector = xSelector;
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
const predicate = (candidate) => {
|
|
4828
|
+
if (typeof candidate.matches !== "function") {
|
|
4829
|
+
// Guard against nodes without matches() (e.g. text nodes).
|
|
4830
|
+
return false;
|
|
4831
|
+
}
|
|
4832
|
+
if (candidate.getAttribute("navi-focusnav") === "ignore") {
|
|
4833
|
+
return false;
|
|
4834
|
+
}
|
|
4835
|
+
// cssSelector check first: cheaper than elementIsFocusable.
|
|
4836
|
+
if (axisCssSelector && !candidate.matches(axisCssSelector)) {
|
|
4837
|
+
return false;
|
|
4838
|
+
}
|
|
4839
|
+
if (!elementIsFocusable(candidate, { excludeAriaHidden })) {
|
|
4840
|
+
return false;
|
|
4841
|
+
}
|
|
4842
|
+
return true;
|
|
4843
|
+
};
|
|
4844
|
+
|
|
4400
4845
|
const targetInLinearGroup = getTargetInLinearFocusGroup(event, element, {
|
|
4401
|
-
direction,
|
|
4402
|
-
loop,
|
|
4846
|
+
direction: axisDirection,
|
|
4847
|
+
loop: axisLoop,
|
|
4403
4848
|
name,
|
|
4849
|
+
predicate,
|
|
4404
4850
|
});
|
|
4405
4851
|
if (!targetInLinearGroup) {
|
|
4852
|
+
// We decided not to loop, but the browser may loop anyway for certain element
|
|
4853
|
+
// types (e.g. radio inputs cycle through their name group on arrow keys).
|
|
4854
|
+
// Return true when the browser would do something we explicitly chose not to
|
|
4855
|
+
// do, so the caller can preventDefault to enforce our decision.
|
|
4856
|
+
if (!axisLoop && browserWouldLoopWithoutPreventDefault(activeElement)) {
|
|
4857
|
+
event.preventDefault();
|
|
4858
|
+
markFocusNav(event);
|
|
4859
|
+
}
|
|
4406
4860
|
return false;
|
|
4407
4861
|
}
|
|
4408
4862
|
onTargetToFocus(targetInLinearGroup);
|
|
@@ -4412,7 +4866,7 @@ const performArrowNavigation = (
|
|
|
4412
4866
|
const getTargetInLinearFocusGroup = (
|
|
4413
4867
|
event,
|
|
4414
4868
|
element,
|
|
4415
|
-
{ direction, loop, name },
|
|
4869
|
+
{ direction, loop, name, predicate },
|
|
4416
4870
|
) => {
|
|
4417
4871
|
const activeElement = document.activeElement;
|
|
4418
4872
|
|
|
@@ -4420,7 +4874,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4420
4874
|
const isJumpToEnd = event.metaKey || event.ctrlKey;
|
|
4421
4875
|
|
|
4422
4876
|
if (isJumpToEnd) {
|
|
4423
|
-
return getJumpToEndTargetLinear(event, element, direction);
|
|
4877
|
+
return getJumpToEndTargetLinear(event, element, direction, predicate);
|
|
4424
4878
|
}
|
|
4425
4879
|
|
|
4426
4880
|
const isForward = isForwardArrow(event, direction);
|
|
@@ -4430,7 +4884,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4430
4884
|
if (!isBackwardArrow(event, direction)) {
|
|
4431
4885
|
break backward;
|
|
4432
4886
|
}
|
|
4433
|
-
const previousElement = findBefore(activeElement,
|
|
4887
|
+
const previousElement = findBefore(activeElement, predicate, {
|
|
4434
4888
|
root: element,
|
|
4435
4889
|
});
|
|
4436
4890
|
if (previousElement) {
|
|
@@ -4438,15 +4892,13 @@ const getTargetInLinearFocusGroup = (
|
|
|
4438
4892
|
}
|
|
4439
4893
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4440
4894
|
name,
|
|
4895
|
+
predicate,
|
|
4441
4896
|
});
|
|
4442
4897
|
if (ancestorTarget) {
|
|
4443
4898
|
return ancestorTarget;
|
|
4444
4899
|
}
|
|
4445
4900
|
if (loop) {
|
|
4446
|
-
const lastFocusableElement = findLastDescendant(
|
|
4447
|
-
element,
|
|
4448
|
-
elementIsFocusable,
|
|
4449
|
-
);
|
|
4901
|
+
const lastFocusableElement = findLastDescendant(element, predicate);
|
|
4450
4902
|
if (lastFocusableElement) {
|
|
4451
4903
|
return lastFocusableElement;
|
|
4452
4904
|
}
|
|
@@ -4459,7 +4911,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4459
4911
|
if (!isForward) {
|
|
4460
4912
|
break forward;
|
|
4461
4913
|
}
|
|
4462
|
-
const nextElement = findAfter(activeElement,
|
|
4914
|
+
const nextElement = findAfter(activeElement, predicate, {
|
|
4463
4915
|
root: element,
|
|
4464
4916
|
});
|
|
4465
4917
|
if (nextElement) {
|
|
@@ -4467,13 +4919,14 @@ const getTargetInLinearFocusGroup = (
|
|
|
4467
4919
|
}
|
|
4468
4920
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4469
4921
|
name,
|
|
4922
|
+
predicate,
|
|
4470
4923
|
});
|
|
4471
4924
|
if (ancestorTarget) {
|
|
4472
4925
|
return ancestorTarget;
|
|
4473
4926
|
}
|
|
4474
4927
|
if (loop) {
|
|
4475
4928
|
// No next element, wrap to first focusable in group
|
|
4476
|
-
const firstFocusableElement = findDescendant(element,
|
|
4929
|
+
const firstFocusableElement = findDescendant(element, predicate);
|
|
4477
4930
|
if (firstFocusableElement) {
|
|
4478
4931
|
return firstFocusableElement;
|
|
4479
4932
|
}
|
|
@@ -4484,7 +4937,11 @@ const getTargetInLinearFocusGroup = (
|
|
|
4484
4937
|
return null;
|
|
4485
4938
|
};
|
|
4486
4939
|
// Find parent focus group with the same name and try delegation
|
|
4487
|
-
const delegateArrowNavigation = (
|
|
4940
|
+
const delegateArrowNavigation = (
|
|
4941
|
+
event,
|
|
4942
|
+
currentElement,
|
|
4943
|
+
{ name, predicate },
|
|
4944
|
+
) => {
|
|
4488
4945
|
let ancestorElement = currentElement.parentElement;
|
|
4489
4946
|
while (ancestorElement) {
|
|
4490
4947
|
const ancestorFocusGroup = getFocusGroup(ancestorElement);
|
|
@@ -4505,6 +4962,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4505
4962
|
direction: ancestorFocusGroup.direction,
|
|
4506
4963
|
loop: ancestorFocusGroup.loop,
|
|
4507
4964
|
name: ancestorFocusGroup.name,
|
|
4965
|
+
predicate,
|
|
4508
4966
|
});
|
|
4509
4967
|
}
|
|
4510
4968
|
}
|
|
@@ -4512,7 +4970,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4512
4970
|
};
|
|
4513
4971
|
|
|
4514
4972
|
// Handle Cmd/Ctrl + arrow keys for linear focus groups to jump to start/end
|
|
4515
|
-
const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
4973
|
+
const getJumpToEndTargetLinear = (event, element, direction, predicate) => {
|
|
4516
4974
|
// Check if this arrow key is valid for the given direction
|
|
4517
4975
|
if (!isForwardArrow(event, direction) && !isBackwardArrow(event, direction)) {
|
|
4518
4976
|
return null;
|
|
@@ -4520,12 +4978,12 @@ const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
|
4520
4978
|
|
|
4521
4979
|
if (isBackwardArrow(event, direction)) {
|
|
4522
4980
|
// Jump to first focusable element in the group
|
|
4523
|
-
return findDescendant(element,
|
|
4981
|
+
return findDescendant(element, predicate);
|
|
4524
4982
|
}
|
|
4525
4983
|
|
|
4526
4984
|
if (isForwardArrow(event, direction)) {
|
|
4527
4985
|
// Jump to last focusable element in the group
|
|
4528
|
-
return findLastDescendant(element,
|
|
4986
|
+
return findLastDescendant(element, predicate);
|
|
4529
4987
|
}
|
|
4530
4988
|
|
|
4531
4989
|
return null;
|
|
@@ -4548,9 +5006,21 @@ const isForwardArrow = (event, direction = "both") => {
|
|
|
4548
5006
|
return forwardKeys[direction]?.includes(event.key) ?? false;
|
|
4549
5007
|
};
|
|
4550
5008
|
|
|
5009
|
+
// We decided not to loop, but the browser may loop anyway for certain element
|
|
5010
|
+
// types (e.g. radio inputs cycle through their name group on arrow keys).
|
|
5011
|
+
// Return true when the browser would do something we explicitly chose not to
|
|
5012
|
+
// do, so the caller can preventDefault to enforce our decision.
|
|
5013
|
+
const browserWouldLoopWithoutPreventDefault = (element) => {
|
|
5014
|
+
if (element.tagName === "INPUT" && element.type === "radio") {
|
|
5015
|
+
// Radio: browser cycles through same-name group on arrow keys
|
|
5016
|
+
return true;
|
|
5017
|
+
}
|
|
5018
|
+
return false;
|
|
5019
|
+
};
|
|
5020
|
+
|
|
4551
5021
|
// Handle arrow navigation inside an HTMLTableElement as a grid.
|
|
4552
5022
|
// Moves focus to adjacent cell in the direction of the arrow key.
|
|
4553
|
-
const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
5023
|
+
const getTargetInTableFocusGroup = (event, table, { loop, predicate }) => {
|
|
4554
5024
|
const arrowKey = event.key;
|
|
4555
5025
|
|
|
4556
5026
|
// Only handle arrow keys
|
|
@@ -4568,7 +5038,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4568
5038
|
|
|
4569
5039
|
// If we're not currently in a table cell, try to focus the first focusable element in the table
|
|
4570
5040
|
if (!currentCell || !table.contains(currentCell)) {
|
|
4571
|
-
return findDescendant(table,
|
|
5041
|
+
return findDescendant(table, predicate) || null;
|
|
4572
5042
|
}
|
|
4573
5043
|
|
|
4574
5044
|
// Get the current position in the table grid
|
|
@@ -4588,6 +5058,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4588
5058
|
allRows,
|
|
4589
5059
|
currentRowIndex,
|
|
4590
5060
|
currentColumnIndex,
|
|
5061
|
+
predicate,
|
|
4591
5062
|
);
|
|
4592
5063
|
}
|
|
4593
5064
|
|
|
@@ -4602,7 +5073,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4602
5073
|
|
|
4603
5074
|
// Find the first cell that is itself focusable
|
|
4604
5075
|
for (const candidateCell of candidateCells) {
|
|
4605
|
-
if (
|
|
5076
|
+
if (predicate(candidateCell)) {
|
|
4606
5077
|
return candidateCell;
|
|
4607
5078
|
}
|
|
4608
5079
|
}
|
|
@@ -4616,6 +5087,7 @@ const getJumpToEndTarget = (
|
|
|
4616
5087
|
allRows,
|
|
4617
5088
|
currentRowIndex,
|
|
4618
5089
|
currentColumnIndex,
|
|
5090
|
+
predicate,
|
|
4619
5091
|
) => {
|
|
4620
5092
|
if (arrowKey === "ArrowRight") {
|
|
4621
5093
|
// Jump to last focusable cell in current row
|
|
@@ -4626,7 +5098,7 @@ const getJumpToEndTarget = (
|
|
|
4626
5098
|
const cells = Array.from(currentRow.cells);
|
|
4627
5099
|
for (let i = cells.length - 1; i >= 0; i--) {
|
|
4628
5100
|
const cell = cells[i];
|
|
4629
|
-
if (
|
|
5101
|
+
if (predicate(cell)) {
|
|
4630
5102
|
return cell;
|
|
4631
5103
|
}
|
|
4632
5104
|
}
|
|
@@ -4640,7 +5112,7 @@ const getJumpToEndTarget = (
|
|
|
4640
5112
|
|
|
4641
5113
|
const cells = Array.from(currentRow.cells);
|
|
4642
5114
|
for (const cell of cells) {
|
|
4643
|
-
if (
|
|
5115
|
+
if (predicate(cell)) {
|
|
4644
5116
|
return cell;
|
|
4645
5117
|
}
|
|
4646
5118
|
}
|
|
@@ -4652,7 +5124,7 @@ const getJumpToEndTarget = (
|
|
|
4652
5124
|
for (let rowIndex = allRows.length - 1; rowIndex >= 0; rowIndex--) {
|
|
4653
5125
|
const row = allRows[rowIndex];
|
|
4654
5126
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4655
|
-
if (cell &&
|
|
5127
|
+
if (cell && predicate(cell)) {
|
|
4656
5128
|
return cell;
|
|
4657
5129
|
}
|
|
4658
5130
|
}
|
|
@@ -4664,7 +5136,7 @@ const getJumpToEndTarget = (
|
|
|
4664
5136
|
for (let rowIndex = 0; rowIndex < allRows.length; rowIndex++) {
|
|
4665
5137
|
const row = allRows[rowIndex];
|
|
4666
5138
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4667
|
-
if (cell &&
|
|
5139
|
+
if (cell && predicate(cell)) {
|
|
4668
5140
|
return cell;
|
|
4669
5141
|
}
|
|
4670
5142
|
}
|
|
@@ -4945,6 +5417,7 @@ const performTabNavigation = (
|
|
|
4945
5417
|
rootElement = document.body,
|
|
4946
5418
|
outsideOfElement = null,
|
|
4947
5419
|
debug = () => {},
|
|
5420
|
+
excludeAriaHidden,
|
|
4948
5421
|
} = {},
|
|
4949
5422
|
) => {
|
|
4950
5423
|
if (!isTabEvent$1(event)) {
|
|
@@ -4967,6 +5440,12 @@ const performTabNavigation = (
|
|
|
4967
5440
|
markFocusNav(event);
|
|
4968
5441
|
targetToFocus.focus();
|
|
4969
5442
|
};
|
|
5443
|
+
const isFocusableByTab = (element) => {
|
|
5444
|
+
if (hasNegativeTabIndex(element)) {
|
|
5445
|
+
return false;
|
|
5446
|
+
}
|
|
5447
|
+
return elementIsFocusable(element, { excludeAriaHidden });
|
|
5448
|
+
};
|
|
4970
5449
|
|
|
4971
5450
|
const predicate = (candidate) => {
|
|
4972
5451
|
const canBeFocusedByTab = isFocusableByTab(candidate);
|
|
@@ -5040,12 +5519,6 @@ const performTabNavigation = (
|
|
|
5040
5519
|
|
|
5041
5520
|
const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
|
|
5042
5521
|
|
|
5043
|
-
const isFocusableByTab = (element) => {
|
|
5044
|
-
if (hasNegativeTabIndex(element)) {
|
|
5045
|
-
return false;
|
|
5046
|
-
}
|
|
5047
|
-
return elementIsFocusable(element);
|
|
5048
|
-
};
|
|
5049
5522
|
const hasNegativeTabIndex = (element) => {
|
|
5050
5523
|
return (
|
|
5051
5524
|
element.hasAttribute &&
|
|
@@ -5064,14 +5537,47 @@ const hasNegativeTabIndex = (element) => {
|
|
|
5064
5537
|
*/
|
|
5065
5538
|
|
|
5066
5539
|
|
|
5540
|
+
/**
|
|
5541
|
+
* Initialises keyboard navigation for a focus group.
|
|
5542
|
+
*
|
|
5543
|
+
* Sets up two keyboard behaviours on the element:
|
|
5544
|
+
* - **Tab**: exits the group, moving focus to the next/previous focusable
|
|
5545
|
+
* element outside the group (standard skip-group behaviour).
|
|
5546
|
+
* - **Arrow keys**: moves focus between focusable descendants according to
|
|
5547
|
+
* the configured direction, wrapping and selector constraints.
|
|
5548
|
+
*
|
|
5549
|
+
* @param {Element} element - The focus-group root element.
|
|
5550
|
+
* @param {object} [options]
|
|
5551
|
+
* @param {boolean} [options.skipTab=true] - When true, Tab exits the group
|
|
5552
|
+
* instead of moving through its children one by one.
|
|
5553
|
+
* @param {string} [options.name] - Optional name shared between related groups
|
|
5554
|
+
* to enable delegation (focus jumps from one named group to another).
|
|
5555
|
+
* @param {boolean} [options.excludeAriaHidden=true] - Skip elements that are
|
|
5556
|
+
* hidden from the accessibility tree (aria-hidden).
|
|
5557
|
+
* @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
|
|
5558
|
+
* "x" = left/right only, "y" = up/down only, "both" = all four arrows.
|
|
5559
|
+
* @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
|
|
5560
|
+
* Omit or pass undefined for no looping on either axis.
|
|
5561
|
+
* @param {string} [options.xSelector] - CSS selector that candidates must match
|
|
5562
|
+
* when navigating on the x axis. Omit to allow any focusable element.
|
|
5563
|
+
* @param {string} [options.ySelector] - CSS selector that candidates must match
|
|
5564
|
+
* when navigating on the y axis. Omit to allow any focusable element.
|
|
5565
|
+
* @returns {{ cleanup: () => void }} Call cleanup() to remove all event listeners.
|
|
5566
|
+
*/
|
|
5067
5567
|
const initFocusGroup = (
|
|
5068
5568
|
element,
|
|
5069
5569
|
{
|
|
5070
|
-
direction = "both",
|
|
5071
5570
|
// extend = true,
|
|
5072
5571
|
skipTab = true,
|
|
5073
|
-
loop = false,
|
|
5074
5572
|
name, // Can be undefined for implicit ancestor-descendant grouping
|
|
5573
|
+
excludeAriaHidden = true,
|
|
5574
|
+
// Which axes are active: "x", "y", or "both" (default)
|
|
5575
|
+
direction = "both",
|
|
5576
|
+
// Which axes loop at boundaries: "x", "y", "both", or undefined (no looping)
|
|
5577
|
+
wrap,
|
|
5578
|
+
// CSS selector to restrict candidates on each axis
|
|
5579
|
+
xSelector,
|
|
5580
|
+
ySelector,
|
|
5075
5581
|
} = {},
|
|
5076
5582
|
) => {
|
|
5077
5583
|
const cleanupCallbackSet = new Set();
|
|
@@ -5085,7 +5591,6 @@ const initFocusGroup = (
|
|
|
5085
5591
|
// Store focus group data in registry
|
|
5086
5592
|
const removeFocusGroup = setFocusGroup(element, {
|
|
5087
5593
|
direction,
|
|
5088
|
-
loop,
|
|
5089
5594
|
name, // Store undefined as-is for implicit grouping
|
|
5090
5595
|
});
|
|
5091
5596
|
cleanupCallbackSet.add(removeFocusGroup);
|
|
@@ -5099,7 +5604,10 @@ const initFocusGroup = (
|
|
|
5099
5604
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
5100
5605
|
return;
|
|
5101
5606
|
}
|
|
5102
|
-
performTabNavigation(event, {
|
|
5607
|
+
performTabNavigation(event, {
|
|
5608
|
+
outsideOfElement: element,
|
|
5609
|
+
excludeAriaHidden,
|
|
5610
|
+
});
|
|
5103
5611
|
};
|
|
5104
5612
|
// Handle Tab navigation (exit group)
|
|
5105
5613
|
element.addEventListener("keydown", handleTabKeyDown, {
|
|
@@ -5123,7 +5631,14 @@ const initFocusGroup = (
|
|
|
5123
5631
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
5124
5632
|
return;
|
|
5125
5633
|
}
|
|
5126
|
-
performArrowNavigation(event, element, {
|
|
5634
|
+
performArrowNavigation(event, element, {
|
|
5635
|
+
name,
|
|
5636
|
+
excludeAriaHidden,
|
|
5637
|
+
direction,
|
|
5638
|
+
wrap,
|
|
5639
|
+
xSelector,
|
|
5640
|
+
ySelector,
|
|
5641
|
+
});
|
|
5127
5642
|
};
|
|
5128
5643
|
element.addEventListener("keydown", handleArrowKeyDown, {
|
|
5129
5644
|
// we must use capture: false to let chance for other part of the code
|
|
@@ -5538,6 +6053,9 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
|
|
|
5538
6053
|
}
|
|
5539
6054
|
return null;
|
|
5540
6055
|
}
|
|
6056
|
+
if (element.hasAttribute("popover") && element.matches(":popover-open")) {
|
|
6057
|
+
return getScrollingElement(element.ownerDocument);
|
|
6058
|
+
}
|
|
5541
6059
|
const position = getStyle(element, "position");
|
|
5542
6060
|
if (position === "fixed") {
|
|
5543
6061
|
return getScrollingElement(element.ownerDocument);
|
|
@@ -6368,13 +6886,19 @@ const getPaddingSizes = (element) => {
|
|
|
6368
6886
|
const trapScrollInside = (element) => {
|
|
6369
6887
|
const cleanupCallbackSet = new Set();
|
|
6370
6888
|
const lockScroll = (el) => {
|
|
6889
|
+
const savedScrollTop = el.scrollTop;
|
|
6890
|
+
const savedScrollLeft = el.scrollLeft;
|
|
6371
6891
|
const scrollbarGutter = getStyle(el, "scrollbar-gutter");
|
|
6372
6892
|
const hasScrollbarGutterStrategy =
|
|
6373
6893
|
scrollbarGutter && scrollbarGutter !== "auto";
|
|
6374
6894
|
if (hasScrollbarGutterStrategy) {
|
|
6375
6895
|
// The element manages its own gutter — just hide overflow, no padding needed.
|
|
6376
6896
|
const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
|
|
6377
|
-
cleanupCallbackSet.add(
|
|
6897
|
+
cleanupCallbackSet.add(() => {
|
|
6898
|
+
removeScrollLockStyles();
|
|
6899
|
+
el.scrollTop = savedScrollTop;
|
|
6900
|
+
el.scrollLeft = savedScrollLeft;
|
|
6901
|
+
});
|
|
6378
6902
|
return;
|
|
6379
6903
|
}
|
|
6380
6904
|
const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
|
|
@@ -6384,7 +6908,11 @@ const trapScrollInside = (element) => {
|
|
|
6384
6908
|
"padding-bottom": `${bottom + scrollbarHeight}px`,
|
|
6385
6909
|
"overflow": "hidden",
|
|
6386
6910
|
});
|
|
6387
|
-
cleanupCallbackSet.add(
|
|
6911
|
+
cleanupCallbackSet.add(() => {
|
|
6912
|
+
removeScrollLockStyles();
|
|
6913
|
+
el.scrollTop = savedScrollTop;
|
|
6914
|
+
el.scrollLeft = savedScrollLeft;
|
|
6915
|
+
});
|
|
6388
6916
|
};
|
|
6389
6917
|
let previous = element.previousSibling;
|
|
6390
6918
|
while (previous) {
|
|
@@ -10397,23 +10925,48 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
|
10397
10925
|
/**
|
|
10398
10926
|
* Tracks how much of an element is visible within its scrollable parent and within the
|
|
10399
10927
|
* document viewport. Calls update() on initialization and whenever visibility changes
|
|
10400
|
-
* (scroll, resize, intersection changes).
|
|
10401
|
-
*
|
|
10402
|
-
*
|
|
10403
|
-
*
|
|
10404
|
-
*
|
|
10405
|
-
*
|
|
10406
|
-
*
|
|
10407
|
-
*
|
|
10408
|
-
*
|
|
10928
|
+
* (scroll, resize, intersection changes, ancestor open/close).
|
|
10929
|
+
*
|
|
10930
|
+
* @param {HTMLElement} element - The element to observe.
|
|
10931
|
+
* @param {function(visibleRect: VisibleRect, info: VisibleRectInfo): void} update - Called on every visibility change.
|
|
10932
|
+
*
|
|
10933
|
+
* @typedef {Object} VisibleRect
|
|
10934
|
+
* @property {number} left - Left edge of the visible area, document-relative (px).
|
|
10935
|
+
* @property {number} top - Top edge of the visible area, document-relative (px).
|
|
10936
|
+
* @property {number} right - Right edge of the visible area, document-relative (px).
|
|
10937
|
+
* @property {number} bottom - Bottom edge of the visible area, document-relative (px).
|
|
10938
|
+
* @property {number} width - Width of the visible area (px).
|
|
10939
|
+
* @property {number} height - Height of the visible area (px).
|
|
10940
|
+
* @property {number} visibilityRatio - Fraction of the element's area truly visible on screen (0–1).
|
|
10941
|
+
* For document scroll containers: viewport-clipped fraction.
|
|
10942
|
+
* For custom containers: fraction clipped by both the container and the viewport.
|
|
10943
|
+
* Is 0 when ancestorClosed is true.
|
|
10944
|
+
*
|
|
10945
|
+
* @typedef {Object} VisibleRectInfo
|
|
10946
|
+
* @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
|
|
10947
|
+
* @property {number} width - Raw getBoundingClientRect() width of the element.
|
|
10948
|
+
* @property {number} height - Raw getBoundingClientRect() height of the element.
|
|
10949
|
+
* @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
|
|
10950
|
+
* currently closed so the element is not rendered. All visibleRect values are 0 in that case.
|
|
10951
|
+
* update() is called immediately on ancestor close and again (with false) on reopen.
|
|
10952
|
+
*
|
|
10953
|
+
* update() is called:
|
|
10954
|
+
* - Once synchronously on initialization (event.type = "initialization")
|
|
10955
|
+
* - On document/container scroll, window resize, element resize, intersection changes, touch move
|
|
10956
|
+
* - Immediately when an ancestor popover/dialog/details opens or closes
|
|
10409
10957
|
*
|
|
10410
10958
|
* A bit like https://tetherjs.dev/ but different
|
|
10411
10959
|
*/
|
|
10412
|
-
const visibleRectEffect = (
|
|
10960
|
+
const visibleRectEffect = (
|
|
10961
|
+
element,
|
|
10962
|
+
update,
|
|
10963
|
+
{ event: initialEvent = new CustomEvent("initialization") } = {},
|
|
10964
|
+
) => {
|
|
10413
10965
|
const [teardown, addTeardown] = createPubSub();
|
|
10414
10966
|
const scrollContainer = getScrollContainer(element);
|
|
10415
10967
|
const scrollContainerIsDocument =
|
|
10416
10968
|
scrollContainer === document.documentElement;
|
|
10969
|
+
let ancestorClosedCount = 0;
|
|
10417
10970
|
const check = (event) => {
|
|
10418
10971
|
|
|
10419
10972
|
// 1. Calculate element position relative to scrollable parent
|
|
@@ -10550,10 +11103,11 @@ const visibleRectEffect = (element, update) => {
|
|
|
10550
11103
|
event,
|
|
10551
11104
|
width,
|
|
10552
11105
|
height,
|
|
11106
|
+
ancestorClosed: ancestorClosedCount > 0,
|
|
10553
11107
|
});
|
|
10554
11108
|
};
|
|
10555
11109
|
|
|
10556
|
-
check(
|
|
11110
|
+
check(initialEvent);
|
|
10557
11111
|
|
|
10558
11112
|
const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
|
|
10559
11113
|
{
|
|
@@ -10683,6 +11237,59 @@ const visibleRectEffect = (element, update) => {
|
|
|
10683
11237
|
});
|
|
10684
11238
|
});
|
|
10685
11239
|
}
|
|
11240
|
+
{
|
|
11241
|
+
let current = element.parentElement;
|
|
11242
|
+
while (current) {
|
|
11243
|
+
if (
|
|
11244
|
+
current.hasAttribute("popover") ||
|
|
11245
|
+
current.tagName === "DIALOG" ||
|
|
11246
|
+
current.tagName === "DETAILS"
|
|
11247
|
+
) {
|
|
11248
|
+
const ancestor = current;
|
|
11249
|
+
const isInitiallyClosed =
|
|
11250
|
+
ancestor.tagName === "DIALOG" || ancestor.tagName === "DETAILS"
|
|
11251
|
+
? !ancestor.open
|
|
11252
|
+
: !ancestor.matches(":popover-open");
|
|
11253
|
+
if (isInitiallyClosed) {
|
|
11254
|
+
ancestorClosedCount++;
|
|
11255
|
+
}
|
|
11256
|
+
// eslint-disable-next-line no-loop-func
|
|
11257
|
+
const onToggle = (e) => {
|
|
11258
|
+
const isClosed =
|
|
11259
|
+
ancestor.tagName === "DETAILS"
|
|
11260
|
+
? !ancestor.open
|
|
11261
|
+
: e.newState === "closed";
|
|
11262
|
+
if (isClosed) {
|
|
11263
|
+
ancestorClosedCount++;
|
|
11264
|
+
update(
|
|
11265
|
+
{
|
|
11266
|
+
left: 0,
|
|
11267
|
+
top: 0,
|
|
11268
|
+
right: 0,
|
|
11269
|
+
bottom: 0,
|
|
11270
|
+
width: 0,
|
|
11271
|
+
height: 0,
|
|
11272
|
+
visibilityRatio: 0,
|
|
11273
|
+
},
|
|
11274
|
+
{ event: e, width: 0, height: 0, ancestorClosed: true },
|
|
11275
|
+
);
|
|
11276
|
+
} else {
|
|
11277
|
+
if (ancestorClosedCount > 0) {
|
|
11278
|
+
ancestorClosedCount--;
|
|
11279
|
+
}
|
|
11280
|
+
if (ancestorClosedCount === 0) {
|
|
11281
|
+
check(e);
|
|
11282
|
+
}
|
|
11283
|
+
}
|
|
11284
|
+
};
|
|
11285
|
+
ancestor.addEventListener("toggle", onToggle);
|
|
11286
|
+
addTeardown(() => {
|
|
11287
|
+
ancestor.removeEventListener("toggle", onToggle);
|
|
11288
|
+
});
|
|
11289
|
+
}
|
|
11290
|
+
current = current.parentElement;
|
|
11291
|
+
}
|
|
11292
|
+
}
|
|
10686
11293
|
}
|
|
10687
11294
|
|
|
10688
11295
|
return {
|
|
@@ -10744,6 +11351,7 @@ const pickPositionRelativeTo = (
|
|
|
10744
11351
|
alignToViewportEdgeWhenAnchorNearEdge = 0,
|
|
10745
11352
|
minLeft = 0,
|
|
10746
11353
|
spacing = 0,
|
|
11354
|
+
alignToAnchorBox = "border-box",
|
|
10747
11355
|
viewportSpacing = 0,
|
|
10748
11356
|
} = {},
|
|
10749
11357
|
) => {
|
|
@@ -10768,10 +11376,30 @@ const pickPositionRelativeTo = (
|
|
|
10768
11376
|
const anchorWidth = anchorRight - anchorLeft;
|
|
10769
11377
|
const anchorHeight = anchorBottom - anchorTop;
|
|
10770
11378
|
|
|
10771
|
-
|
|
10772
|
-
|
|
10773
|
-
|
|
10774
|
-
|
|
11379
|
+
// alignToAnchorBox controls whether the element aligns to the anchor's border-box (outer edge)
|
|
11380
|
+
// or content-box (inner content area, ignoring padding and border).
|
|
11381
|
+
// content-box lets the arrow point into the content area instead of the outer edge.
|
|
11382
|
+
// Insets are directional: top/bottom for Y-axis, left/right for X-axis.
|
|
11383
|
+
// When positioning above, only the top inset applies (content-box top edge).
|
|
11384
|
+
// When positioning below, only the bottom inset applies (content-box bottom edge).
|
|
11385
|
+
let insetTop = 0;
|
|
11386
|
+
let insetBottom = 0;
|
|
11387
|
+
let insetLeft = 0;
|
|
11388
|
+
let insetRight = 0;
|
|
11389
|
+
if (alignToAnchorBox === "content-box") {
|
|
11390
|
+
const anchorBorderSizes = getBorderSizes(anchor);
|
|
11391
|
+
const anchorPaddingSizes = getPaddingSizes(anchor);
|
|
11392
|
+
insetTop = anchorBorderSizes.top + anchorPaddingSizes.top;
|
|
11393
|
+
insetBottom = anchorBorderSizes.bottom + anchorPaddingSizes.bottom;
|
|
11394
|
+
insetLeft = anchorBorderSizes.left + anchorPaddingSizes.left;
|
|
11395
|
+
insetRight = anchorBorderSizes.right + anchorPaddingSizes.right;
|
|
11396
|
+
}
|
|
11397
|
+
const spaceAbove = anchorTop + insetTop;
|
|
11398
|
+
const spaceBelow = viewportHeight - anchorBottom + insetBottom;
|
|
11399
|
+
const effectiveAnchorLeft = anchorLeft + insetLeft;
|
|
11400
|
+
const effectiveAnchorRight = anchorRight - insetRight;
|
|
11401
|
+
const spaceLeft = anchorLeft + insetLeft;
|
|
11402
|
+
const spaceRight = viewportWidth - anchorRight + insetRight;
|
|
10775
11403
|
|
|
10776
11404
|
// Resolve active X and Y, and whether each is fixed (no flip fallback)
|
|
10777
11405
|
let activeX;
|
|
@@ -10845,7 +11473,11 @@ const pickPositionRelativeTo = (
|
|
|
10845
11473
|
if (currentFitsEnough) {
|
|
10846
11474
|
finalY = activeY;
|
|
10847
11475
|
} else {
|
|
10848
|
-
|
|
11476
|
+
// Only flip if the opposite side has more space — avoids oscillation
|
|
11477
|
+
// when neither side has enough room (both fail the ratio).
|
|
11478
|
+
const opposite = oppositeY[activeY];
|
|
11479
|
+
const oppositeHasMoreSpace = spaceFor(opposite) > spaceFor(activeY);
|
|
11480
|
+
finalY = oppositeHasMoreSpace ? opposite : activeY;
|
|
10849
11481
|
}
|
|
10850
11482
|
}
|
|
10851
11483
|
}
|
|
@@ -10908,44 +11540,49 @@ const pickPositionRelativeTo = (
|
|
|
10908
11540
|
let elementPositionLeft;
|
|
10909
11541
|
{
|
|
10910
11542
|
if (finalX === "to-the-left") {
|
|
10911
|
-
elementPositionLeft =
|
|
11543
|
+
elementPositionLeft = effectiveAnchorLeft - elementWidth - spacing;
|
|
10912
11544
|
} else if (finalX === "left-aligned") {
|
|
10913
|
-
elementPositionLeft =
|
|
11545
|
+
elementPositionLeft = effectiveAnchorLeft;
|
|
10914
11546
|
} else if (finalX === "center") {
|
|
10915
11547
|
// Complex logic handles wide anchors and viewport-edge snapping
|
|
10916
11548
|
const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
|
|
10917
11549
|
if (anchorIsWiderThanViewport) {
|
|
10918
|
-
const anchorLeftIsVisible =
|
|
10919
|
-
const anchorRightIsVisible =
|
|
11550
|
+
const anchorLeftIsVisible = effectiveAnchorLeft >= 0;
|
|
11551
|
+
const anchorRightIsVisible = effectiveAnchorRight <= viewportWidth;
|
|
10920
11552
|
if (!anchorLeftIsVisible && anchorRightIsVisible) {
|
|
10921
11553
|
const viewportCenter = viewportWidth / 2;
|
|
10922
|
-
const distanceFromRightEdge = viewportWidth -
|
|
11554
|
+
const distanceFromRightEdge = viewportWidth - effectiveAnchorRight;
|
|
10923
11555
|
elementPositionLeft =
|
|
10924
11556
|
viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
10925
11557
|
} else if (anchorLeftIsVisible && !anchorRightIsVisible) {
|
|
10926
11558
|
const viewportCenter = viewportWidth / 2;
|
|
10927
|
-
const distanceFromLeftEdge = -
|
|
11559
|
+
const distanceFromLeftEdge = -effectiveAnchorLeft;
|
|
10928
11560
|
elementPositionLeft =
|
|
10929
11561
|
viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
10930
11562
|
} else {
|
|
10931
11563
|
elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
|
|
10932
11564
|
}
|
|
10933
11565
|
} else {
|
|
10934
|
-
elementPositionLeft =
|
|
11566
|
+
elementPositionLeft =
|
|
11567
|
+
effectiveAnchorLeft +
|
|
11568
|
+
(effectiveAnchorRight - effectiveAnchorLeft) / 2 -
|
|
11569
|
+
elementWidth / 2;
|
|
10935
11570
|
if (alignToViewportEdgeWhenAnchorNearEdge) {
|
|
10936
|
-
const
|
|
11571
|
+
const effectiveAnchorWidth =
|
|
11572
|
+
effectiveAnchorRight - effectiveAnchorLeft;
|
|
11573
|
+
const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
|
|
10937
11574
|
const anchorIsNearLeftEdge =
|
|
10938
|
-
|
|
11575
|
+
effectiveAnchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
|
|
10939
11576
|
if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
|
|
10940
11577
|
elementPositionLeft = minLeft;
|
|
10941
11578
|
}
|
|
10942
11579
|
}
|
|
10943
11580
|
}
|
|
10944
11581
|
} else if (finalX === "right-aligned") {
|
|
10945
|
-
elementPositionLeft =
|
|
11582
|
+
elementPositionLeft = effectiveAnchorRight - elementWidth;
|
|
10946
11583
|
} else {
|
|
10947
11584
|
// "to-the-right"
|
|
10948
|
-
elementPositionLeft =
|
|
11585
|
+
elementPositionLeft = effectiveAnchorRight + spacing;
|
|
10949
11586
|
}
|
|
10950
11587
|
// Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
|
|
10951
11588
|
if (elementPositionLeft < viewportSpacing) {
|
|
@@ -10962,8 +11599,8 @@ const pickPositionRelativeTo = (
|
|
|
10962
11599
|
let elementPositionTop;
|
|
10963
11600
|
{
|
|
10964
11601
|
if (finalY === "above") {
|
|
10965
|
-
// top is always anchorTop - elementHeight - spacing — max-height truncates if needed.
|
|
10966
|
-
const idealTop = anchorTop - elementHeight - spacing;
|
|
11602
|
+
// top is always anchorTop + insetTop - elementHeight - spacing — max-height truncates if needed.
|
|
11603
|
+
const idealTop = anchorTop + insetTop - elementHeight - spacing;
|
|
10967
11604
|
elementPositionTop =
|
|
10968
11605
|
idealTop < viewportSpacing ? viewportSpacing : idealTop;
|
|
10969
11606
|
} else if (finalY === "above-overlap") {
|
|
@@ -10978,9 +11615,9 @@ const pickPositionRelativeTo = (
|
|
|
10978
11615
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10979
11616
|
} else {
|
|
10980
11617
|
// "below"
|
|
10981
|
-
// top is always anchorBottom + spacing — max-height (via --space-available) truncates
|
|
11618
|
+
// top is always anchorBottom - insetBottom + spacing — max-height (via --space-available) truncates
|
|
10982
11619
|
// the element height so it doesn't overflow the viewport bottom.
|
|
10983
|
-
const idealTop = anchorBottom + spacing;
|
|
11620
|
+
const idealTop = anchorBottom - insetBottom + spacing;
|
|
10984
11621
|
elementPositionTop =
|
|
10985
11622
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10986
11623
|
}
|
|
@@ -14031,6 +14668,144 @@ const getMaxWidth = (
|
|
|
14031
14668
|
return maxWidth;
|
|
14032
14669
|
};
|
|
14033
14670
|
|
|
14671
|
+
/**
|
|
14672
|
+
* Measures the width of the longest rendered visual line inside an element.
|
|
14673
|
+
*
|
|
14674
|
+
* Useful for solving the CSS "shrinkwrap" problem: when multi-line text sits
|
|
14675
|
+
* inside a `max-width` container, CSS expands the element to fill all
|
|
14676
|
+
* available space, leaving trailing whitespace to the right of the text.
|
|
14677
|
+
* Setting an explicit width equal to the longest line eliminates that gap.
|
|
14678
|
+
* See shrinkwrap_demo.html for a visual explanation.
|
|
14679
|
+
*
|
|
14680
|
+
* Returns `null` when all content fits on a single visual line (nothing to
|
|
14681
|
+
* optimize). Returns the pixel width of the widest line when text wraps to
|
|
14682
|
+
* two or more lines.
|
|
14683
|
+
*
|
|
14684
|
+
* ## Implementation note — bounding extent, not sum of widths
|
|
14685
|
+
*
|
|
14686
|
+
* `range.getClientRects()` returns one rect per layout box intersecting the
|
|
14687
|
+
* range. Nested elements (e.g. `<span><span>text</span></span>`) produce
|
|
14688
|
+
* multiple overlapping rects for the exact same pixels on the same line.
|
|
14689
|
+
* Summing their `width` values therefore over-counts the true line width.
|
|
14690
|
+
*
|
|
14691
|
+
* Instead we compute the bounding extent per line: track the minimum `left`
|
|
14692
|
+
* and maximum `right` across all rects sharing the same rounded `top`, then
|
|
14693
|
+
* use `right - left` as the line width. This is correct regardless of nesting
|
|
14694
|
+
* depth and works well for regular inline text content.
|
|
14695
|
+
*
|
|
14696
|
+
* Limitation: rects are grouped by `Math.round(r.top)`, so elements on the
|
|
14697
|
+
* same visual line but with slightly different baselines (e.g. an icon taller
|
|
14698
|
+
* than surrounding text) could be counted as separate lines. This is unlikely
|
|
14699
|
+
* to matter in practice for normal text rendering.
|
|
14700
|
+
*
|
|
14701
|
+
* Limitation: `range.getClientRects()` returns rects for text nodes and inline
|
|
14702
|
+
* boxes as laid out in the flow, ignoring any `overflow: hidden` or `max-width`
|
|
14703
|
+
* clipping applied to ancestor elements. If child elements clip their own
|
|
14704
|
+
* content (e.g. badges with `overflow: hidden` and `max-width`), the rects
|
|
14705
|
+
* will reflect the unclipped text size, producing a width larger than what is
|
|
14706
|
+
* visually rendered. In that case prefer `measureWidestChildRow`, which uses
|
|
14707
|
+
* each child's own `getBoundingClientRect()` and therefore respects clipping.
|
|
14708
|
+
*
|
|
14709
|
+
* @param {Element} el - The element whose text content should be measured.
|
|
14710
|
+
* @returns {number|null} Width in pixels of the longest visual line,
|
|
14711
|
+
* or `null` if there is only one visual line.
|
|
14712
|
+
*/
|
|
14713
|
+
const measureLongestVisualLineWidth = (el) => {
|
|
14714
|
+
const range = document.createRange();
|
|
14715
|
+
range.selectNodeContents(el);
|
|
14716
|
+
|
|
14717
|
+
const lineBoundsByTop = new Map();
|
|
14718
|
+
for (const r of range.getClientRects()) {
|
|
14719
|
+
if (r.width === 0) {
|
|
14720
|
+
continue;
|
|
14721
|
+
}
|
|
14722
|
+
const top = Math.round(r.top);
|
|
14723
|
+
const existing = lineBoundsByTop.get(top);
|
|
14724
|
+
if (existing === undefined) {
|
|
14725
|
+
lineBoundsByTop.set(top, { left: r.left, right: r.right });
|
|
14726
|
+
} else {
|
|
14727
|
+
if (r.left < existing.left) {
|
|
14728
|
+
existing.left = r.left;
|
|
14729
|
+
}
|
|
14730
|
+
if (r.right > existing.right) {
|
|
14731
|
+
existing.right = r.right;
|
|
14732
|
+
}
|
|
14733
|
+
}
|
|
14734
|
+
}
|
|
14735
|
+
|
|
14736
|
+
if (lineBoundsByTop.size <= 1) {
|
|
14737
|
+
return null;
|
|
14738
|
+
}
|
|
14739
|
+
|
|
14740
|
+
let longestLineWidth = 0;
|
|
14741
|
+
for (const { left, right } of lineBoundsByTop.values()) {
|
|
14742
|
+
const w = right - left;
|
|
14743
|
+
if (w > longestLineWidth) {
|
|
14744
|
+
longestLineWidth = w;
|
|
14745
|
+
}
|
|
14746
|
+
}
|
|
14747
|
+
return longestLineWidth;
|
|
14748
|
+
};
|
|
14749
|
+
|
|
14750
|
+
// Measures the width of the widest row of direct children.
|
|
14751
|
+
// Uses children's bounding rects (which respect overflow:hidden / max-width)
|
|
14752
|
+
// rather than Range.getClientRects() which sees through clipping boundaries.
|
|
14753
|
+
// Returns null when all children fit on a single row (nothing to optimize).
|
|
14754
|
+
const measureWidestChildRow = (el) => {
|
|
14755
|
+
const children = Array.from(el.children);
|
|
14756
|
+
if (children.length === 0) {
|
|
14757
|
+
return null;
|
|
14758
|
+
}
|
|
14759
|
+
|
|
14760
|
+
const containerStyle = getComputedStyle(el);
|
|
14761
|
+
const paddingLeft = parseFloat(containerStyle.paddingLeft);
|
|
14762
|
+
const paddingRight = parseFloat(containerStyle.paddingRight);
|
|
14763
|
+
const borderLeft = parseFloat(containerStyle.borderLeftWidth);
|
|
14764
|
+
const borderRight = parseFloat(containerStyle.borderRightWidth);
|
|
14765
|
+
|
|
14766
|
+
// Group children by row using their top position
|
|
14767
|
+
const rowsByTop = new Map();
|
|
14768
|
+
for (const child of children) {
|
|
14769
|
+
const rect = child.getBoundingClientRect();
|
|
14770
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
14771
|
+
continue;
|
|
14772
|
+
}
|
|
14773
|
+
const top = Math.round(rect.top);
|
|
14774
|
+
const existing = rowsByTop.get(top);
|
|
14775
|
+
if (existing === undefined) {
|
|
14776
|
+
rowsByTop.set(top, { left: rect.left, right: rect.right });
|
|
14777
|
+
} else {
|
|
14778
|
+
if (rect.left < existing.left) {
|
|
14779
|
+
existing.left = rect.left;
|
|
14780
|
+
}
|
|
14781
|
+
if (rect.right > existing.right) {
|
|
14782
|
+
existing.right = rect.right;
|
|
14783
|
+
}
|
|
14784
|
+
}
|
|
14785
|
+
}
|
|
14786
|
+
|
|
14787
|
+
if (rowsByTop.size <= 1) {
|
|
14788
|
+
return null;
|
|
14789
|
+
}
|
|
14790
|
+
|
|
14791
|
+
let widestRowWidth = 0;
|
|
14792
|
+
for (const { left, right } of rowsByTop.values()) {
|
|
14793
|
+
const rowWidth = right - left;
|
|
14794
|
+
if (rowWidth > widestRowWidth) {
|
|
14795
|
+
widestRowWidth = rowWidth;
|
|
14796
|
+
}
|
|
14797
|
+
}
|
|
14798
|
+
|
|
14799
|
+
// Convert from absolute pixel width to the container's content-box width
|
|
14800
|
+
// so that setting el.style.width = result + "px" works correctly.
|
|
14801
|
+
if (containerStyle.boxSizing === "border-box") {
|
|
14802
|
+
return (
|
|
14803
|
+
widestRowWidth + paddingLeft + paddingRight + borderLeft + borderRight
|
|
14804
|
+
);
|
|
14805
|
+
}
|
|
14806
|
+
return widestRowWidth;
|
|
14807
|
+
};
|
|
14808
|
+
|
|
14034
14809
|
const useAvailableHeight = (elementRef) => {
|
|
14035
14810
|
const [availableHeight, availableHeightSetter] = useState(-1);
|
|
14036
14811
|
|
|
@@ -14147,4 +14922,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
|
|
|
14147
14922
|
};
|
|
14148
14923
|
};
|
|
14149
14924
|
|
|
14150
|
-
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles,
|
|
14925
|
+
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 };
|