@jsenv/dom 0.11.2 → 0.12.0

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