@jsenv/dom 0.11.3 → 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 +899 -144
- 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,6 +4348,33 @@ const canInteract = (element) => {
|
|
|
4257
4348
|
return true;
|
|
4258
4349
|
};
|
|
4259
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
|
+
|
|
4260
4378
|
const findFocusable = (element) => {
|
|
4261
4379
|
const associatedElements = getAssociatedElements(element);
|
|
4262
4380
|
if (associatedElements) {
|
|
@@ -4272,58 +4390,265 @@ const findFocusable = (element) => {
|
|
|
4272
4390
|
return element;
|
|
4273
4391
|
}
|
|
4274
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
|
+
}
|
|
4275
4413
|
return focusableDescendant;
|
|
4276
4414
|
};
|
|
4277
4415
|
|
|
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;
|
|
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";
|
|
4314
4434
|
}
|
|
4315
|
-
|
|
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
|
|
4316
4443
|
if (
|
|
4317
4444
|
target.disabled ||
|
|
4318
4445
|
target.closest("[disabled]") ||
|
|
4319
4446
|
target.inert ||
|
|
4320
4447
|
target.closest("[inert]")
|
|
4321
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) {
|
|
4322
4479
|
return false;
|
|
4323
4480
|
}
|
|
4324
|
-
|
|
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;
|
|
4325
4491
|
};
|
|
4326
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
|
+
|
|
4327
4652
|
// WeakMap to store focus group metadata
|
|
4328
4653
|
const focusGroupRegistry = new WeakMap();
|
|
4329
4654
|
|
|
@@ -4365,12 +4690,50 @@ const markFocusNav = (event) => {
|
|
|
4365
4690
|
focusNavEventMarker.mark(event);
|
|
4366
4691
|
};
|
|
4367
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
|
+
*/
|
|
4368
4714
|
const performArrowNavigation = (
|
|
4369
4715
|
event,
|
|
4370
4716
|
element,
|
|
4371
|
-
{
|
|
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
|
+
} = {},
|
|
4372
4728
|
) => {
|
|
4373
|
-
|
|
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) {
|
|
4374
4737
|
return false;
|
|
4375
4738
|
}
|
|
4376
4739
|
const activeElement = document.activeElement;
|
|
@@ -4389,7 +4752,23 @@ const performArrowNavigation = (
|
|
|
4389
4752
|
// Grid navigation: we support only TABLE element for now
|
|
4390
4753
|
// A role="table" or an element with display: table could be used too but for now we need only TABLE support
|
|
4391
4754
|
if (element.tagName === "TABLE") {
|
|
4392
|
-
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
|
+
});
|
|
4393
4772
|
if (!targetInGrid) {
|
|
4394
4773
|
return false;
|
|
4395
4774
|
}
|
|
@@ -4397,12 +4776,67 @@ const performArrowNavigation = (
|
|
|
4397
4776
|
return true;
|
|
4398
4777
|
}
|
|
4399
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
|
+
|
|
4400
4825
|
const targetInLinearGroup = getTargetInLinearFocusGroup(event, element, {
|
|
4401
|
-
direction,
|
|
4402
|
-
loop,
|
|
4826
|
+
direction: axisDirection,
|
|
4827
|
+
loop: axisLoop,
|
|
4403
4828
|
name,
|
|
4829
|
+
predicate,
|
|
4404
4830
|
});
|
|
4405
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
|
+
}
|
|
4406
4840
|
return false;
|
|
4407
4841
|
}
|
|
4408
4842
|
onTargetToFocus(targetInLinearGroup);
|
|
@@ -4412,7 +4846,7 @@ const performArrowNavigation = (
|
|
|
4412
4846
|
const getTargetInLinearFocusGroup = (
|
|
4413
4847
|
event,
|
|
4414
4848
|
element,
|
|
4415
|
-
{ direction, loop, name },
|
|
4849
|
+
{ direction, loop, name, predicate },
|
|
4416
4850
|
) => {
|
|
4417
4851
|
const activeElement = document.activeElement;
|
|
4418
4852
|
|
|
@@ -4420,7 +4854,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4420
4854
|
const isJumpToEnd = event.metaKey || event.ctrlKey;
|
|
4421
4855
|
|
|
4422
4856
|
if (isJumpToEnd) {
|
|
4423
|
-
return getJumpToEndTargetLinear(event, element, direction);
|
|
4857
|
+
return getJumpToEndTargetLinear(event, element, direction, predicate);
|
|
4424
4858
|
}
|
|
4425
4859
|
|
|
4426
4860
|
const isForward = isForwardArrow(event, direction);
|
|
@@ -4430,7 +4864,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4430
4864
|
if (!isBackwardArrow(event, direction)) {
|
|
4431
4865
|
break backward;
|
|
4432
4866
|
}
|
|
4433
|
-
const previousElement = findBefore(activeElement,
|
|
4867
|
+
const previousElement = findBefore(activeElement, predicate, {
|
|
4434
4868
|
root: element,
|
|
4435
4869
|
});
|
|
4436
4870
|
if (previousElement) {
|
|
@@ -4438,15 +4872,13 @@ const getTargetInLinearFocusGroup = (
|
|
|
4438
4872
|
}
|
|
4439
4873
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4440
4874
|
name,
|
|
4875
|
+
predicate,
|
|
4441
4876
|
});
|
|
4442
4877
|
if (ancestorTarget) {
|
|
4443
4878
|
return ancestorTarget;
|
|
4444
4879
|
}
|
|
4445
4880
|
if (loop) {
|
|
4446
|
-
const lastFocusableElement = findLastDescendant(
|
|
4447
|
-
element,
|
|
4448
|
-
elementIsFocusable,
|
|
4449
|
-
);
|
|
4881
|
+
const lastFocusableElement = findLastDescendant(element, predicate);
|
|
4450
4882
|
if (lastFocusableElement) {
|
|
4451
4883
|
return lastFocusableElement;
|
|
4452
4884
|
}
|
|
@@ -4459,7 +4891,7 @@ const getTargetInLinearFocusGroup = (
|
|
|
4459
4891
|
if (!isForward) {
|
|
4460
4892
|
break forward;
|
|
4461
4893
|
}
|
|
4462
|
-
const nextElement = findAfter(activeElement,
|
|
4894
|
+
const nextElement = findAfter(activeElement, predicate, {
|
|
4463
4895
|
root: element,
|
|
4464
4896
|
});
|
|
4465
4897
|
if (nextElement) {
|
|
@@ -4467,13 +4899,14 @@ const getTargetInLinearFocusGroup = (
|
|
|
4467
4899
|
}
|
|
4468
4900
|
const ancestorTarget = delegateArrowNavigation(event, element, {
|
|
4469
4901
|
name,
|
|
4902
|
+
predicate,
|
|
4470
4903
|
});
|
|
4471
4904
|
if (ancestorTarget) {
|
|
4472
4905
|
return ancestorTarget;
|
|
4473
4906
|
}
|
|
4474
4907
|
if (loop) {
|
|
4475
4908
|
// No next element, wrap to first focusable in group
|
|
4476
|
-
const firstFocusableElement = findDescendant(element,
|
|
4909
|
+
const firstFocusableElement = findDescendant(element, predicate);
|
|
4477
4910
|
if (firstFocusableElement) {
|
|
4478
4911
|
return firstFocusableElement;
|
|
4479
4912
|
}
|
|
@@ -4484,7 +4917,11 @@ const getTargetInLinearFocusGroup = (
|
|
|
4484
4917
|
return null;
|
|
4485
4918
|
};
|
|
4486
4919
|
// Find parent focus group with the same name and try delegation
|
|
4487
|
-
const delegateArrowNavigation = (
|
|
4920
|
+
const delegateArrowNavigation = (
|
|
4921
|
+
event,
|
|
4922
|
+
currentElement,
|
|
4923
|
+
{ name, predicate },
|
|
4924
|
+
) => {
|
|
4488
4925
|
let ancestorElement = currentElement.parentElement;
|
|
4489
4926
|
while (ancestorElement) {
|
|
4490
4927
|
const ancestorFocusGroup = getFocusGroup(ancestorElement);
|
|
@@ -4505,6 +4942,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4505
4942
|
direction: ancestorFocusGroup.direction,
|
|
4506
4943
|
loop: ancestorFocusGroup.loop,
|
|
4507
4944
|
name: ancestorFocusGroup.name,
|
|
4945
|
+
predicate,
|
|
4508
4946
|
});
|
|
4509
4947
|
}
|
|
4510
4948
|
}
|
|
@@ -4512,7 +4950,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
|
|
|
4512
4950
|
};
|
|
4513
4951
|
|
|
4514
4952
|
// Handle Cmd/Ctrl + arrow keys for linear focus groups to jump to start/end
|
|
4515
|
-
const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
4953
|
+
const getJumpToEndTargetLinear = (event, element, direction, predicate) => {
|
|
4516
4954
|
// Check if this arrow key is valid for the given direction
|
|
4517
4955
|
if (!isForwardArrow(event, direction) && !isBackwardArrow(event, direction)) {
|
|
4518
4956
|
return null;
|
|
@@ -4520,12 +4958,12 @@ const getJumpToEndTargetLinear = (event, element, direction) => {
|
|
|
4520
4958
|
|
|
4521
4959
|
if (isBackwardArrow(event, direction)) {
|
|
4522
4960
|
// Jump to first focusable element in the group
|
|
4523
|
-
return findDescendant(element,
|
|
4961
|
+
return findDescendant(element, predicate);
|
|
4524
4962
|
}
|
|
4525
4963
|
|
|
4526
4964
|
if (isForwardArrow(event, direction)) {
|
|
4527
4965
|
// Jump to last focusable element in the group
|
|
4528
|
-
return findLastDescendant(element,
|
|
4966
|
+
return findLastDescendant(element, predicate);
|
|
4529
4967
|
}
|
|
4530
4968
|
|
|
4531
4969
|
return null;
|
|
@@ -4548,9 +4986,21 @@ const isForwardArrow = (event, direction = "both") => {
|
|
|
4548
4986
|
return forwardKeys[direction]?.includes(event.key) ?? false;
|
|
4549
4987
|
};
|
|
4550
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
|
+
|
|
4551
5001
|
// Handle arrow navigation inside an HTMLTableElement as a grid.
|
|
4552
5002
|
// Moves focus to adjacent cell in the direction of the arrow key.
|
|
4553
|
-
const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
5003
|
+
const getTargetInTableFocusGroup = (event, table, { loop, predicate }) => {
|
|
4554
5004
|
const arrowKey = event.key;
|
|
4555
5005
|
|
|
4556
5006
|
// Only handle arrow keys
|
|
@@ -4568,7 +5018,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4568
5018
|
|
|
4569
5019
|
// If we're not currently in a table cell, try to focus the first focusable element in the table
|
|
4570
5020
|
if (!currentCell || !table.contains(currentCell)) {
|
|
4571
|
-
return findDescendant(table,
|
|
5021
|
+
return findDescendant(table, predicate) || null;
|
|
4572
5022
|
}
|
|
4573
5023
|
|
|
4574
5024
|
// Get the current position in the table grid
|
|
@@ -4588,6 +5038,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4588
5038
|
allRows,
|
|
4589
5039
|
currentRowIndex,
|
|
4590
5040
|
currentColumnIndex,
|
|
5041
|
+
predicate,
|
|
4591
5042
|
);
|
|
4592
5043
|
}
|
|
4593
5044
|
|
|
@@ -4602,7 +5053,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
|
|
|
4602
5053
|
|
|
4603
5054
|
// Find the first cell that is itself focusable
|
|
4604
5055
|
for (const candidateCell of candidateCells) {
|
|
4605
|
-
if (
|
|
5056
|
+
if (predicate(candidateCell)) {
|
|
4606
5057
|
return candidateCell;
|
|
4607
5058
|
}
|
|
4608
5059
|
}
|
|
@@ -4616,6 +5067,7 @@ const getJumpToEndTarget = (
|
|
|
4616
5067
|
allRows,
|
|
4617
5068
|
currentRowIndex,
|
|
4618
5069
|
currentColumnIndex,
|
|
5070
|
+
predicate,
|
|
4619
5071
|
) => {
|
|
4620
5072
|
if (arrowKey === "ArrowRight") {
|
|
4621
5073
|
// Jump to last focusable cell in current row
|
|
@@ -4626,7 +5078,7 @@ const getJumpToEndTarget = (
|
|
|
4626
5078
|
const cells = Array.from(currentRow.cells);
|
|
4627
5079
|
for (let i = cells.length - 1; i >= 0; i--) {
|
|
4628
5080
|
const cell = cells[i];
|
|
4629
|
-
if (
|
|
5081
|
+
if (predicate(cell)) {
|
|
4630
5082
|
return cell;
|
|
4631
5083
|
}
|
|
4632
5084
|
}
|
|
@@ -4640,7 +5092,7 @@ const getJumpToEndTarget = (
|
|
|
4640
5092
|
|
|
4641
5093
|
const cells = Array.from(currentRow.cells);
|
|
4642
5094
|
for (const cell of cells) {
|
|
4643
|
-
if (
|
|
5095
|
+
if (predicate(cell)) {
|
|
4644
5096
|
return cell;
|
|
4645
5097
|
}
|
|
4646
5098
|
}
|
|
@@ -4652,7 +5104,7 @@ const getJumpToEndTarget = (
|
|
|
4652
5104
|
for (let rowIndex = allRows.length - 1; rowIndex >= 0; rowIndex--) {
|
|
4653
5105
|
const row = allRows[rowIndex];
|
|
4654
5106
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4655
|
-
if (cell &&
|
|
5107
|
+
if (cell && predicate(cell)) {
|
|
4656
5108
|
return cell;
|
|
4657
5109
|
}
|
|
4658
5110
|
}
|
|
@@ -4664,7 +5116,7 @@ const getJumpToEndTarget = (
|
|
|
4664
5116
|
for (let rowIndex = 0; rowIndex < allRows.length; rowIndex++) {
|
|
4665
5117
|
const row = allRows[rowIndex];
|
|
4666
5118
|
const cell = row?.cells?.[currentColumnIndex];
|
|
4667
|
-
if (cell &&
|
|
5119
|
+
if (cell && predicate(cell)) {
|
|
4668
5120
|
return cell;
|
|
4669
5121
|
}
|
|
4670
5122
|
}
|
|
@@ -4945,6 +5397,7 @@ const performTabNavigation = (
|
|
|
4945
5397
|
rootElement = document.body,
|
|
4946
5398
|
outsideOfElement = null,
|
|
4947
5399
|
debug = () => {},
|
|
5400
|
+
excludeAriaHidden,
|
|
4948
5401
|
} = {},
|
|
4949
5402
|
) => {
|
|
4950
5403
|
if (!isTabEvent$1(event)) {
|
|
@@ -4967,6 +5420,12 @@ const performTabNavigation = (
|
|
|
4967
5420
|
markFocusNav(event);
|
|
4968
5421
|
targetToFocus.focus();
|
|
4969
5422
|
};
|
|
5423
|
+
const isFocusableByTab = (element) => {
|
|
5424
|
+
if (hasNegativeTabIndex(element)) {
|
|
5425
|
+
return false;
|
|
5426
|
+
}
|
|
5427
|
+
return elementIsFocusable(element, { excludeAriaHidden });
|
|
5428
|
+
};
|
|
4970
5429
|
|
|
4971
5430
|
const predicate = (candidate) => {
|
|
4972
5431
|
const canBeFocusedByTab = isFocusableByTab(candidate);
|
|
@@ -5040,12 +5499,6 @@ const performTabNavigation = (
|
|
|
5040
5499
|
|
|
5041
5500
|
const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
|
|
5042
5501
|
|
|
5043
|
-
const isFocusableByTab = (element) => {
|
|
5044
|
-
if (hasNegativeTabIndex(element)) {
|
|
5045
|
-
return false;
|
|
5046
|
-
}
|
|
5047
|
-
return elementIsFocusable(element);
|
|
5048
|
-
};
|
|
5049
5502
|
const hasNegativeTabIndex = (element) => {
|
|
5050
5503
|
return (
|
|
5051
5504
|
element.hasAttribute &&
|
|
@@ -5064,14 +5517,47 @@ const hasNegativeTabIndex = (element) => {
|
|
|
5064
5517
|
*/
|
|
5065
5518
|
|
|
5066
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
|
+
*/
|
|
5067
5547
|
const initFocusGroup = (
|
|
5068
5548
|
element,
|
|
5069
5549
|
{
|
|
5070
|
-
direction = "both",
|
|
5071
5550
|
// extend = true,
|
|
5072
5551
|
skipTab = true,
|
|
5073
|
-
loop = false,
|
|
5074
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,
|
|
5075
5561
|
} = {},
|
|
5076
5562
|
) => {
|
|
5077
5563
|
const cleanupCallbackSet = new Set();
|
|
@@ -5085,7 +5571,6 @@ const initFocusGroup = (
|
|
|
5085
5571
|
// Store focus group data in registry
|
|
5086
5572
|
const removeFocusGroup = setFocusGroup(element, {
|
|
5087
5573
|
direction,
|
|
5088
|
-
loop,
|
|
5089
5574
|
name, // Store undefined as-is for implicit grouping
|
|
5090
5575
|
});
|
|
5091
5576
|
cleanupCallbackSet.add(removeFocusGroup);
|
|
@@ -5099,7 +5584,10 @@ const initFocusGroup = (
|
|
|
5099
5584
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
5100
5585
|
return;
|
|
5101
5586
|
}
|
|
5102
|
-
performTabNavigation(event, {
|
|
5587
|
+
performTabNavigation(event, {
|
|
5588
|
+
outsideOfElement: element,
|
|
5589
|
+
excludeAriaHidden,
|
|
5590
|
+
});
|
|
5103
5591
|
};
|
|
5104
5592
|
// Handle Tab navigation (exit group)
|
|
5105
5593
|
element.addEventListener("keydown", handleTabKeyDown, {
|
|
@@ -5123,7 +5611,14 @@ const initFocusGroup = (
|
|
|
5123
5611
|
// Prevent double handling of the same event + allow preventing focus nav from outside
|
|
5124
5612
|
return;
|
|
5125
5613
|
}
|
|
5126
|
-
performArrowNavigation(event, element, {
|
|
5614
|
+
performArrowNavigation(event, element, {
|
|
5615
|
+
name,
|
|
5616
|
+
excludeAriaHidden,
|
|
5617
|
+
direction,
|
|
5618
|
+
wrap,
|
|
5619
|
+
xSelector,
|
|
5620
|
+
ySelector,
|
|
5621
|
+
});
|
|
5127
5622
|
};
|
|
5128
5623
|
element.addEventListener("keydown", handleArrowKeyDown, {
|
|
5129
5624
|
// we must use capture: false to let chance for other part of the code
|
|
@@ -5538,6 +6033,9 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
|
|
|
5538
6033
|
}
|
|
5539
6034
|
return null;
|
|
5540
6035
|
}
|
|
6036
|
+
if (element.hasAttribute("popover") && element.matches(":popover-open")) {
|
|
6037
|
+
return getScrollingElement(element.ownerDocument);
|
|
6038
|
+
}
|
|
5541
6039
|
const position = getStyle(element, "position");
|
|
5542
6040
|
if (position === "fixed") {
|
|
5543
6041
|
return getScrollingElement(element.ownerDocument);
|
|
@@ -6368,13 +6866,19 @@ const getPaddingSizes = (element) => {
|
|
|
6368
6866
|
const trapScrollInside = (element) => {
|
|
6369
6867
|
const cleanupCallbackSet = new Set();
|
|
6370
6868
|
const lockScroll = (el) => {
|
|
6869
|
+
const savedScrollTop = el.scrollTop;
|
|
6870
|
+
const savedScrollLeft = el.scrollLeft;
|
|
6371
6871
|
const scrollbarGutter = getStyle(el, "scrollbar-gutter");
|
|
6372
6872
|
const hasScrollbarGutterStrategy =
|
|
6373
6873
|
scrollbarGutter && scrollbarGutter !== "auto";
|
|
6374
6874
|
if (hasScrollbarGutterStrategy) {
|
|
6375
6875
|
// The element manages its own gutter — just hide overflow, no padding needed.
|
|
6376
6876
|
const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
|
|
6377
|
-
cleanupCallbackSet.add(
|
|
6877
|
+
cleanupCallbackSet.add(() => {
|
|
6878
|
+
removeScrollLockStyles();
|
|
6879
|
+
el.scrollTop = savedScrollTop;
|
|
6880
|
+
el.scrollLeft = savedScrollLeft;
|
|
6881
|
+
});
|
|
6378
6882
|
return;
|
|
6379
6883
|
}
|
|
6380
6884
|
const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
|
|
@@ -6384,7 +6888,11 @@ const trapScrollInside = (element) => {
|
|
|
6384
6888
|
"padding-bottom": `${bottom + scrollbarHeight}px`,
|
|
6385
6889
|
"overflow": "hidden",
|
|
6386
6890
|
});
|
|
6387
|
-
cleanupCallbackSet.add(
|
|
6891
|
+
cleanupCallbackSet.add(() => {
|
|
6892
|
+
removeScrollLockStyles();
|
|
6893
|
+
el.scrollTop = savedScrollTop;
|
|
6894
|
+
el.scrollLeft = savedScrollLeft;
|
|
6895
|
+
});
|
|
6388
6896
|
};
|
|
6389
6897
|
let previous = element.previousSibling;
|
|
6390
6898
|
while (previous) {
|
|
@@ -10397,23 +10905,48 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
|
10397
10905
|
/**
|
|
10398
10906
|
* Tracks how much of an element is visible within its scrollable parent and within the
|
|
10399
10907
|
* document viewport. Calls update() on initialization and whenever visibility changes
|
|
10400
|
-
* (scroll, resize, intersection changes).
|
|
10401
|
-
*
|
|
10402
|
-
*
|
|
10403
|
-
*
|
|
10404
|
-
*
|
|
10405
|
-
*
|
|
10406
|
-
*
|
|
10407
|
-
*
|
|
10408
|
-
*
|
|
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
|
|
10409
10937
|
*
|
|
10410
10938
|
* A bit like https://tetherjs.dev/ but different
|
|
10411
10939
|
*/
|
|
10412
|
-
const visibleRectEffect = (
|
|
10940
|
+
const visibleRectEffect = (
|
|
10941
|
+
element,
|
|
10942
|
+
update,
|
|
10943
|
+
{ event: initialEvent = new CustomEvent("initialization") } = {},
|
|
10944
|
+
) => {
|
|
10413
10945
|
const [teardown, addTeardown] = createPubSub();
|
|
10414
10946
|
const scrollContainer = getScrollContainer(element);
|
|
10415
10947
|
const scrollContainerIsDocument =
|
|
10416
10948
|
scrollContainer === document.documentElement;
|
|
10949
|
+
let ancestorClosedCount = 0;
|
|
10417
10950
|
const check = (event) => {
|
|
10418
10951
|
|
|
10419
10952
|
// 1. Calculate element position relative to scrollable parent
|
|
@@ -10550,10 +11083,11 @@ const visibleRectEffect = (element, update) => {
|
|
|
10550
11083
|
event,
|
|
10551
11084
|
width,
|
|
10552
11085
|
height,
|
|
11086
|
+
ancestorClosed: ancestorClosedCount > 0,
|
|
10553
11087
|
});
|
|
10554
11088
|
};
|
|
10555
11089
|
|
|
10556
|
-
check(
|
|
11090
|
+
check(initialEvent);
|
|
10557
11091
|
|
|
10558
11092
|
const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
|
|
10559
11093
|
{
|
|
@@ -10683,6 +11217,59 @@ const visibleRectEffect = (element, update) => {
|
|
|
10683
11217
|
});
|
|
10684
11218
|
});
|
|
10685
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
|
+
}
|
|
10686
11273
|
}
|
|
10687
11274
|
|
|
10688
11275
|
return {
|
|
@@ -10744,6 +11331,7 @@ const pickPositionRelativeTo = (
|
|
|
10744
11331
|
alignToViewportEdgeWhenAnchorNearEdge = 0,
|
|
10745
11332
|
minLeft = 0,
|
|
10746
11333
|
spacing = 0,
|
|
11334
|
+
alignToAnchorBox = "border-box",
|
|
10747
11335
|
viewportSpacing = 0,
|
|
10748
11336
|
} = {},
|
|
10749
11337
|
) => {
|
|
@@ -10768,10 +11356,30 @@ const pickPositionRelativeTo = (
|
|
|
10768
11356
|
const anchorWidth = anchorRight - anchorLeft;
|
|
10769
11357
|
const anchorHeight = anchorBottom - anchorTop;
|
|
10770
11358
|
|
|
10771
|
-
|
|
10772
|
-
|
|
10773
|
-
|
|
10774
|
-
|
|
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;
|
|
10775
11383
|
|
|
10776
11384
|
// Resolve active X and Y, and whether each is fixed (no flip fallback)
|
|
10777
11385
|
let activeX;
|
|
@@ -10845,7 +11453,11 @@ const pickPositionRelativeTo = (
|
|
|
10845
11453
|
if (currentFitsEnough) {
|
|
10846
11454
|
finalY = activeY;
|
|
10847
11455
|
} else {
|
|
10848
|
-
|
|
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;
|
|
10849
11461
|
}
|
|
10850
11462
|
}
|
|
10851
11463
|
}
|
|
@@ -10908,44 +11520,49 @@ const pickPositionRelativeTo = (
|
|
|
10908
11520
|
let elementPositionLeft;
|
|
10909
11521
|
{
|
|
10910
11522
|
if (finalX === "to-the-left") {
|
|
10911
|
-
elementPositionLeft =
|
|
11523
|
+
elementPositionLeft = effectiveAnchorLeft - elementWidth - spacing;
|
|
10912
11524
|
} else if (finalX === "left-aligned") {
|
|
10913
|
-
elementPositionLeft =
|
|
11525
|
+
elementPositionLeft = effectiveAnchorLeft;
|
|
10914
11526
|
} else if (finalX === "center") {
|
|
10915
11527
|
// Complex logic handles wide anchors and viewport-edge snapping
|
|
10916
11528
|
const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
|
|
10917
11529
|
if (anchorIsWiderThanViewport) {
|
|
10918
|
-
const anchorLeftIsVisible =
|
|
10919
|
-
const anchorRightIsVisible =
|
|
11530
|
+
const anchorLeftIsVisible = effectiveAnchorLeft >= 0;
|
|
11531
|
+
const anchorRightIsVisible = effectiveAnchorRight <= viewportWidth;
|
|
10920
11532
|
if (!anchorLeftIsVisible && anchorRightIsVisible) {
|
|
10921
11533
|
const viewportCenter = viewportWidth / 2;
|
|
10922
|
-
const distanceFromRightEdge = viewportWidth -
|
|
11534
|
+
const distanceFromRightEdge = viewportWidth - effectiveAnchorRight;
|
|
10923
11535
|
elementPositionLeft =
|
|
10924
11536
|
viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
10925
11537
|
} else if (anchorLeftIsVisible && !anchorRightIsVisible) {
|
|
10926
11538
|
const viewportCenter = viewportWidth / 2;
|
|
10927
|
-
const distanceFromLeftEdge = -
|
|
11539
|
+
const distanceFromLeftEdge = -effectiveAnchorLeft;
|
|
10928
11540
|
elementPositionLeft =
|
|
10929
11541
|
viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
10930
11542
|
} else {
|
|
10931
11543
|
elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
|
|
10932
11544
|
}
|
|
10933
11545
|
} else {
|
|
10934
|
-
elementPositionLeft =
|
|
11546
|
+
elementPositionLeft =
|
|
11547
|
+
effectiveAnchorLeft +
|
|
11548
|
+
(effectiveAnchorRight - effectiveAnchorLeft) / 2 -
|
|
11549
|
+
elementWidth / 2;
|
|
10935
11550
|
if (alignToViewportEdgeWhenAnchorNearEdge) {
|
|
10936
|
-
const
|
|
11551
|
+
const effectiveAnchorWidth =
|
|
11552
|
+
effectiveAnchorRight - effectiveAnchorLeft;
|
|
11553
|
+
const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
|
|
10937
11554
|
const anchorIsNearLeftEdge =
|
|
10938
|
-
|
|
11555
|
+
effectiveAnchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
|
|
10939
11556
|
if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
|
|
10940
11557
|
elementPositionLeft = minLeft;
|
|
10941
11558
|
}
|
|
10942
11559
|
}
|
|
10943
11560
|
}
|
|
10944
11561
|
} else if (finalX === "right-aligned") {
|
|
10945
|
-
elementPositionLeft =
|
|
11562
|
+
elementPositionLeft = effectiveAnchorRight - elementWidth;
|
|
10946
11563
|
} else {
|
|
10947
11564
|
// "to-the-right"
|
|
10948
|
-
elementPositionLeft =
|
|
11565
|
+
elementPositionLeft = effectiveAnchorRight + spacing;
|
|
10949
11566
|
}
|
|
10950
11567
|
// Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
|
|
10951
11568
|
if (elementPositionLeft < viewportSpacing) {
|
|
@@ -10962,8 +11579,8 @@ const pickPositionRelativeTo = (
|
|
|
10962
11579
|
let elementPositionTop;
|
|
10963
11580
|
{
|
|
10964
11581
|
if (finalY === "above") {
|
|
10965
|
-
// top is always anchorTop - elementHeight - spacing — max-height truncates if needed.
|
|
10966
|
-
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;
|
|
10967
11584
|
elementPositionTop =
|
|
10968
11585
|
idealTop < viewportSpacing ? viewportSpacing : idealTop;
|
|
10969
11586
|
} else if (finalY === "above-overlap") {
|
|
@@ -10978,9 +11595,9 @@ const pickPositionRelativeTo = (
|
|
|
10978
11595
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10979
11596
|
} else {
|
|
10980
11597
|
// "below"
|
|
10981
|
-
// top is always anchorBottom + spacing — max-height (via --space-available) truncates
|
|
11598
|
+
// top is always anchorBottom - insetBottom + spacing — max-height (via --space-available) truncates
|
|
10982
11599
|
// the element height so it doesn't overflow the viewport bottom.
|
|
10983
|
-
const idealTop = anchorBottom + spacing;
|
|
11600
|
+
const idealTop = anchorBottom - insetBottom + spacing;
|
|
10984
11601
|
elementPositionTop =
|
|
10985
11602
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
10986
11603
|
}
|
|
@@ -14031,6 +14648,144 @@ const getMaxWidth = (
|
|
|
14031
14648
|
return maxWidth;
|
|
14032
14649
|
};
|
|
14033
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
|
+
|
|
14034
14789
|
const useAvailableHeight = (elementRef) => {
|
|
14035
14790
|
const [availableHeight, availableHeightSetter] = useState(-1);
|
|
14036
14791
|
|
|
@@ -14147,4 +14902,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
|
|
|
14147
14902
|
};
|
|
14148
14903
|
};
|
|
14149
14904
|
|
|
14150
|
-
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 };
|