@jsenv/dom 0.11.3 → 0.12.1

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