@jsenv/dom 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +899 -144
  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,6 +4348,33 @@ const canInteract = (element) => {
4257
4348
  return true;
4258
4349
  };
4259
4350
 
4351
+ /**
4352
+ * Given an element with the `navi-focus-delegate` attribute, returns the first
4353
+ * focusable ancestor that should receive focus instead.
4354
+ *
4355
+ * Elements marked with `navi-focus-delegate` opt out of being focusable
4356
+ * themselves (see {@link elementIsFocusable}) and redirect focus upward to
4357
+ * their nearest focusable ancestor.
4358
+ *
4359
+ * Returns `null` when the attribute is absent or no focusable ancestor exists.
4360
+ *
4361
+ * @param {Element} el
4362
+ * @returns {Element|null}
4363
+ */
4364
+ const findFocusDelegateTarget = (el) => {
4365
+ if (!el.hasAttribute("navi-focus-delegate")) {
4366
+ return null;
4367
+ }
4368
+ let ancestor = el.parentElement;
4369
+ while (ancestor) {
4370
+ if (elementIsFocusable(ancestor)) {
4371
+ return ancestor;
4372
+ }
4373
+ ancestor = ancestor.parentElement;
4374
+ }
4375
+ return null;
4376
+ };
4377
+
4260
4378
  const findFocusable = (element) => {
4261
4379
  const associatedElements = getAssociatedElements(element);
4262
4380
  if (associatedElements) {
@@ -4272,58 +4390,265 @@ const findFocusable = (element) => {
4272
4390
  return element;
4273
4391
  }
4274
4392
  const focusableDescendant = findDescendant(element, elementIsFocusable);
4393
+ if (focusableDescendant) {
4394
+ // If the first focusable is an unchecked radio/checkbox, prefer the checked
4395
+ // sibling in the same group (mirrors native browser radio focus behavior
4396
+ // and gives focus to the selected item in a selectable list).
4397
+ const { tagName, type, name } = focusableDescendant;
4398
+ if (
4399
+ tagName === "INPUT" &&
4400
+ (type === "radio" || type === "checkbox") &&
4401
+ !focusableDescendant.checked &&
4402
+ name
4403
+ ) {
4404
+ const groupContainer = focusableDescendant.form || document;
4405
+ const checkedInput = groupContainer.querySelector(
4406
+ `input[type="${type}"][name="${CSS.escape(name)}"]:checked`,
4407
+ );
4408
+ if (checkedInput) {
4409
+ return checkedInput;
4410
+ }
4411
+ }
4412
+ }
4275
4413
  return focusableDescendant;
4276
4414
  };
4277
4415
 
4278
- // 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;
4416
+ /**
4417
+ * Returns the browser's default action for a keyboard event on its target element.
4418
+ *
4419
+ * Possible return values:
4420
+ * - `"activate"` — Space/Enter triggers the element's primary action (button click, checkbox toggle, picker open…)
4421
+ * - `"form_submit"` — Enter submits the enclosing form (single-line inputs)
4422
+ * - `"dismiss"` — Escape closes a dialog, clears a search field, collapses a dropdown
4423
+ * - `"focus_nav"` — key moves focus (Tab, arrow keys in a radio/checkbox group)
4424
+ * - `"value_change"` — key increments/decrements the field value (range, number, date…)
4425
+ * - `"cursor_move"` — key moves the text cursor within the field
4426
+ * - `"type"` — key produces or deletes text content
4427
+ * - `"scroll"` — key scrolls the page or a scrollable container
4428
+ * - `""` — no meaningful browser default; safe to intercept freely
4429
+ */
4430
+ const normalizeKeyboardKey = (rawKey) => {
4431
+ // The browser sends " " for the Space bar; map it to the friendly name "space"
4432
+ if (rawKey === " ") {
4433
+ return "space";
4314
4434
  }
4315
- // 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
4316
4443
  if (
4317
4444
  target.disabled ||
4318
4445
  target.closest("[disabled]") ||
4319
4446
  target.inert ||
4320
4447
  target.closest("[inert]")
4321
4448
  ) {
4449
+ return "";
4450
+ }
4451
+ for (const { test, keys, fallback } of DEFAULT_BEHAVIORS) {
4452
+ if (!test(target)) {
4453
+ continue;
4454
+ }
4455
+ if (Object.hasOwn(keys, key)) {
4456
+ const value = keys[key];
4457
+ const defaultActionForKey =
4458
+ typeof value === "function" ? value(keyboardEvent) : value;
4459
+ if (defaultActionForKey !== undefined) {
4460
+ return defaultActionForKey;
4461
+ }
4462
+ }
4463
+ if (fallback === undefined) {
4464
+ // This entry only handles specific keys — keep looking for other entries
4465
+ continue;
4466
+ }
4467
+ const defaultAction =
4468
+ typeof fallback === "function" ? fallback(keyboardEvent) : fallback;
4469
+ if (defaultAction !== undefined) {
4470
+ return defaultAction;
4471
+ }
4472
+ }
4473
+ return "";
4474
+ };
4475
+
4476
+ const isTypingIntent = (e) => {
4477
+ // Modifier keys used for shortcuts: skip
4478
+ if (e.metaKey || e.ctrlKey) {
4322
4479
  return false;
4323
4480
  }
4324
- 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;
4325
4491
  };
4326
4492
 
4493
+ const DEFAULT_BEHAVIORS = [
4494
+ {
4495
+ test: () => true,
4496
+ keys: {
4497
+ // Tab moves focus on any element
4498
+ tab: "focus_nav",
4499
+ // Escape dismisses on any element (dialog, search clear, dropdown close, etc.)
4500
+ escape: "dismiss",
4501
+ },
4502
+ // no fallback: only claims Tab/Escape, other keys continue to next entries
4503
+ },
4504
+ {
4505
+ test: (el) => el.matches("input[type='radio'], input[type='checkbox']"),
4506
+ keys: {
4507
+ space: "activate",
4508
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4509
+ arrowleft: "focus_nav",
4510
+ arrowright: "focus_nav",
4511
+ arrowup: "focus_nav",
4512
+ arrowdown: "focus_nav",
4513
+ },
4514
+ },
4515
+ {
4516
+ test: (el) =>
4517
+ el.matches(
4518
+ "input:not([type]), input[type='text'], input[type='search'], input[type='url'], input[type='email'], input[type='password'], input[type='tel']",
4519
+ ),
4520
+ keys: {
4521
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4522
+ arrowleft: "cursor_move",
4523
+ arrowright: "cursor_move",
4524
+ arrowup: "cursor_move",
4525
+ arrowdown: "cursor_move",
4526
+ home: "cursor_move",
4527
+ end: "cursor_move",
4528
+ },
4529
+ fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
4530
+ },
4531
+ {
4532
+ test: (el) => el.matches("input[type='range']"),
4533
+ keys: {
4534
+ space: "scroll",
4535
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4536
+ arrowleft: "value_change",
4537
+ arrowright: "value_change",
4538
+ arrowup: "value_change",
4539
+ arrowdown: "value_change",
4540
+ home: "value_change",
4541
+ end: "value_change",
4542
+ pageup: "value_change",
4543
+ pagedown: "value_change",
4544
+ },
4545
+ },
4546
+ {
4547
+ test: (el) => el.matches("input[type='number']"),
4548
+ keys: {
4549
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4550
+ arrowleft: "cursor_move",
4551
+ arrowright: "cursor_move",
4552
+ arrowup: "value_change",
4553
+ arrowdown: "value_change",
4554
+ home: "cursor_move",
4555
+ end: "cursor_move",
4556
+ },
4557
+ fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
4558
+ },
4559
+ {
4560
+ test: (el) =>
4561
+ el.matches(
4562
+ "input[type='date'], input[type='time'], input[type='datetime-local'], input[type='month'], input[type='week']",
4563
+ ),
4564
+ keys: {
4565
+ space: "activate",
4566
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4567
+ arrowleft: "value_change",
4568
+ arrowright: "value_change",
4569
+ arrowup: "value_change",
4570
+ arrowdown: "value_change",
4571
+ },
4572
+ },
4573
+ {
4574
+ // Color input: Space opens the color picker, Enter submits the form
4575
+ test: (el) => el.matches("input[type='color']"),
4576
+ keys: {
4577
+ space: "activate",
4578
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4579
+ },
4580
+ },
4581
+ {
4582
+ // File input: Space opens the picker, Enter submits the form
4583
+ test: (el) => el.matches("input[type='file']"),
4584
+ keys: {
4585
+ space: "activate",
4586
+ enter: (e) => (e.target.form ? "form_submit" : ""),
4587
+ },
4588
+ },
4589
+ {
4590
+ // Generic INPUT fallback for any remaining input types
4591
+ test: (el) => el.tagName === "INPUT",
4592
+ keys: {},
4593
+ fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
4594
+ },
4595
+ {
4596
+ test: (el) =>
4597
+ el.tagName === "TEXTAREA" ||
4598
+ el.contentEditable === "true" ||
4599
+ el.isContentEditable,
4600
+ keys: {
4601
+ enter: "type",
4602
+ arrowleft: "cursor_move",
4603
+ arrowright: "cursor_move",
4604
+ arrowup: "cursor_move",
4605
+ arrowdown: "cursor_move",
4606
+ home: "cursor_move",
4607
+ end: "cursor_move",
4608
+ },
4609
+ fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
4610
+ },
4611
+ {
4612
+ // Buttons and links: Space/Enter trigger the element's default action
4613
+ test: (el) =>
4614
+ el.tagName === "BUTTON" ||
4615
+ el.tagName === "A" ||
4616
+ el.getAttribute("role") === "button",
4617
+ keys: {
4618
+ space: "activate",
4619
+ enter: "activate",
4620
+ },
4621
+ },
4622
+ {
4623
+ // details/summary: Space/Enter toggle the disclosure widget
4624
+ test: (el) => el.tagName === "DETAILS" || el.tagName === "SUMMARY",
4625
+ keys: {
4626
+ space: "activate",
4627
+ enter: "activate",
4628
+ },
4629
+ },
4630
+ {
4631
+ // SELECT: don't intercept anything while the dropdown may be open
4632
+ test: (el) => el.tagName === "SELECT",
4633
+ keys: {},
4634
+ },
4635
+ {
4636
+ // Non-interactive elements: browser scrolls on Space and arrow keys
4637
+ test: () => true,
4638
+ keys: {
4639
+ space: "scroll",
4640
+ arrowup: "scroll",
4641
+ arrowdown: "scroll",
4642
+ arrowleft: "scroll",
4643
+ arrowright: "scroll",
4644
+ pageup: "scroll",
4645
+ pagedown: "scroll",
4646
+ home: "scroll",
4647
+ end: "scroll",
4648
+ },
4649
+ },
4650
+ ];
4651
+
4327
4652
  // WeakMap to store focus group metadata
4328
4653
  const focusGroupRegistry = new WeakMap();
4329
4654
 
@@ -4365,12 +4690,50 @@ const markFocusNav = (event) => {
4365
4690
  focusNavEventMarker.mark(event);
4366
4691
  };
4367
4692
 
4693
+ /**
4694
+ * Performs arrow-key navigation within a focus group element.
4695
+ *
4696
+ * Called on every keydown event inside the group. Decides whether the pressed
4697
+ * key should move focus to another element, and if so, which one.
4698
+ *
4699
+ * @param {KeyboardEvent} event - The keydown event.
4700
+ * @param {Element} element - The focus-group root element.
4701
+ * @param {object} [options]
4702
+ * @param {string} [options.name] - Optional group name used for ancestor delegation.
4703
+ * @param {boolean} [options.excludeAriaHidden=true] - Skip elements hidden from the accessibility tree.
4704
+ * @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
4705
+ * "x" = left/right only, "y" = up/down only, "both" = all four arrows.
4706
+ * @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
4707
+ * Omit or pass undefined for no looping on either axis.
4708
+ * @param {string} [options.xSelector] - CSS selector that candidates must match
4709
+ * when navigating on the x axis. Omit to allow any focusable element.
4710
+ * @param {string} [options.ySelector] - CSS selector that candidates must match
4711
+ * when navigating on the y axis. Omit to allow any focusable element.
4712
+ * @returns {boolean} True if the event was handled (focus moved or default prevented).
4713
+ */
4368
4714
  const performArrowNavigation = (
4369
4715
  event,
4370
4716
  element,
4371
- { 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
+ } = {},
4372
4728
  ) => {
4373
- 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) {
4374
4737
  return false;
4375
4738
  }
4376
4739
  const activeElement = document.activeElement;
@@ -4389,7 +4752,23 @@ const performArrowNavigation = (
4389
4752
  // Grid navigation: we support only TABLE element for now
4390
4753
  // A role="table" or an element with display: table could be used too but for now we need only TABLE support
4391
4754
  if (element.tagName === "TABLE") {
4392
- const 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
+ });
4393
4772
  if (!targetInGrid) {
4394
4773
  return false;
4395
4774
  }
@@ -4397,12 +4776,67 @@ const performArrowNavigation = (
4397
4776
  return true;
4398
4777
  }
4399
4778
 
4779
+ // Linear navigation: detect which axis the pressed key belongs to.
4780
+ const isVerticalKey = event.key === "ArrowUp" || event.key === "ArrowDown";
4781
+ const isHorizontalKey =
4782
+ event.key === "ArrowLeft" || event.key === "ArrowRight";
4783
+ if (!isVerticalKey && !isHorizontalKey) {
4784
+ return false;
4785
+ }
4786
+
4787
+ // Check whether this axis is enabled and resolve its loop + cssSelector.
4788
+ let axisDirection;
4789
+ let axisLoop;
4790
+ let axisCssSelector;
4791
+ if (isVerticalKey) {
4792
+ if (direction !== "both" && direction !== "y") {
4793
+ return false;
4794
+ }
4795
+ axisDirection = "vertical";
4796
+ axisLoop = wrap === "both" || wrap === "y";
4797
+ axisCssSelector = ySelector;
4798
+ } else {
4799
+ if (direction !== "both" && direction !== "x") {
4800
+ return false;
4801
+ }
4802
+ axisDirection = "horizontal";
4803
+ axisLoop = wrap === "both" || wrap === "x";
4804
+ axisCssSelector = xSelector;
4805
+ }
4806
+
4807
+ const predicate = (candidate) => {
4808
+ if (typeof candidate.matches !== "function") {
4809
+ // Guard against nodes without matches() (e.g. text nodes).
4810
+ return false;
4811
+ }
4812
+ if (candidate.getAttribute("navi-focusnav") === "ignore") {
4813
+ return false;
4814
+ }
4815
+ // cssSelector check first: cheaper than elementIsFocusable.
4816
+ if (axisCssSelector && !candidate.matches(axisCssSelector)) {
4817
+ return false;
4818
+ }
4819
+ if (!elementIsFocusable(candidate, { excludeAriaHidden })) {
4820
+ return false;
4821
+ }
4822
+ return true;
4823
+ };
4824
+
4400
4825
  const targetInLinearGroup = getTargetInLinearFocusGroup(event, element, {
4401
- direction,
4402
- loop,
4826
+ direction: axisDirection,
4827
+ loop: axisLoop,
4403
4828
  name,
4829
+ predicate,
4404
4830
  });
4405
4831
  if (!targetInLinearGroup) {
4832
+ // We decided not to loop, but the browser may loop anyway for certain element
4833
+ // types (e.g. radio inputs cycle through their name group on arrow keys).
4834
+ // Return true when the browser would do something we explicitly chose not to
4835
+ // do, so the caller can preventDefault to enforce our decision.
4836
+ if (!axisLoop && browserWouldLoopWithoutPreventDefault(activeElement)) {
4837
+ event.preventDefault();
4838
+ markFocusNav(event);
4839
+ }
4406
4840
  return false;
4407
4841
  }
4408
4842
  onTargetToFocus(targetInLinearGroup);
@@ -4412,7 +4846,7 @@ const performArrowNavigation = (
4412
4846
  const getTargetInLinearFocusGroup = (
4413
4847
  event,
4414
4848
  element,
4415
- { direction, loop, name },
4849
+ { direction, loop, name, predicate },
4416
4850
  ) => {
4417
4851
  const activeElement = document.activeElement;
4418
4852
 
@@ -4420,7 +4854,7 @@ const getTargetInLinearFocusGroup = (
4420
4854
  const isJumpToEnd = event.metaKey || event.ctrlKey;
4421
4855
 
4422
4856
  if (isJumpToEnd) {
4423
- return getJumpToEndTargetLinear(event, element, direction);
4857
+ return getJumpToEndTargetLinear(event, element, direction, predicate);
4424
4858
  }
4425
4859
 
4426
4860
  const isForward = isForwardArrow(event, direction);
@@ -4430,7 +4864,7 @@ const getTargetInLinearFocusGroup = (
4430
4864
  if (!isBackwardArrow(event, direction)) {
4431
4865
  break backward;
4432
4866
  }
4433
- const previousElement = findBefore(activeElement, elementIsFocusable, {
4867
+ const previousElement = findBefore(activeElement, predicate, {
4434
4868
  root: element,
4435
4869
  });
4436
4870
  if (previousElement) {
@@ -4438,15 +4872,13 @@ const getTargetInLinearFocusGroup = (
4438
4872
  }
4439
4873
  const ancestorTarget = delegateArrowNavigation(event, element, {
4440
4874
  name,
4875
+ predicate,
4441
4876
  });
4442
4877
  if (ancestorTarget) {
4443
4878
  return ancestorTarget;
4444
4879
  }
4445
4880
  if (loop) {
4446
- const lastFocusableElement = findLastDescendant(
4447
- element,
4448
- elementIsFocusable,
4449
- );
4881
+ const lastFocusableElement = findLastDescendant(element, predicate);
4450
4882
  if (lastFocusableElement) {
4451
4883
  return lastFocusableElement;
4452
4884
  }
@@ -4459,7 +4891,7 @@ const getTargetInLinearFocusGroup = (
4459
4891
  if (!isForward) {
4460
4892
  break forward;
4461
4893
  }
4462
- const nextElement = findAfter(activeElement, elementIsFocusable, {
4894
+ const nextElement = findAfter(activeElement, predicate, {
4463
4895
  root: element,
4464
4896
  });
4465
4897
  if (nextElement) {
@@ -4467,13 +4899,14 @@ const getTargetInLinearFocusGroup = (
4467
4899
  }
4468
4900
  const ancestorTarget = delegateArrowNavigation(event, element, {
4469
4901
  name,
4902
+ predicate,
4470
4903
  });
4471
4904
  if (ancestorTarget) {
4472
4905
  return ancestorTarget;
4473
4906
  }
4474
4907
  if (loop) {
4475
4908
  // No next element, wrap to first focusable in group
4476
- const firstFocusableElement = findDescendant(element, elementIsFocusable);
4909
+ const firstFocusableElement = findDescendant(element, predicate);
4477
4910
  if (firstFocusableElement) {
4478
4911
  return firstFocusableElement;
4479
4912
  }
@@ -4484,7 +4917,11 @@ const getTargetInLinearFocusGroup = (
4484
4917
  return null;
4485
4918
  };
4486
4919
  // Find parent focus group with the same name and try delegation
4487
- const delegateArrowNavigation = (event, currentElement, { name }) => {
4920
+ const delegateArrowNavigation = (
4921
+ event,
4922
+ currentElement,
4923
+ { name, predicate },
4924
+ ) => {
4488
4925
  let ancestorElement = currentElement.parentElement;
4489
4926
  while (ancestorElement) {
4490
4927
  const ancestorFocusGroup = getFocusGroup(ancestorElement);
@@ -4505,6 +4942,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
4505
4942
  direction: ancestorFocusGroup.direction,
4506
4943
  loop: ancestorFocusGroup.loop,
4507
4944
  name: ancestorFocusGroup.name,
4945
+ predicate,
4508
4946
  });
4509
4947
  }
4510
4948
  }
@@ -4512,7 +4950,7 @@ const delegateArrowNavigation = (event, currentElement, { name }) => {
4512
4950
  };
4513
4951
 
4514
4952
  // Handle Cmd/Ctrl + arrow keys for linear focus groups to jump to start/end
4515
- const getJumpToEndTargetLinear = (event, element, direction) => {
4953
+ const getJumpToEndTargetLinear = (event, element, direction, predicate) => {
4516
4954
  // Check if this arrow key is valid for the given direction
4517
4955
  if (!isForwardArrow(event, direction) && !isBackwardArrow(event, direction)) {
4518
4956
  return null;
@@ -4520,12 +4958,12 @@ const getJumpToEndTargetLinear = (event, element, direction) => {
4520
4958
 
4521
4959
  if (isBackwardArrow(event, direction)) {
4522
4960
  // Jump to first focusable element in the group
4523
- return findDescendant(element, elementIsFocusable);
4961
+ return findDescendant(element, predicate);
4524
4962
  }
4525
4963
 
4526
4964
  if (isForwardArrow(event, direction)) {
4527
4965
  // Jump to last focusable element in the group
4528
- return findLastDescendant(element, elementIsFocusable);
4966
+ return findLastDescendant(element, predicate);
4529
4967
  }
4530
4968
 
4531
4969
  return null;
@@ -4548,9 +4986,21 @@ const isForwardArrow = (event, direction = "both") => {
4548
4986
  return forwardKeys[direction]?.includes(event.key) ?? false;
4549
4987
  };
4550
4988
 
4989
+ // We decided not to loop, but the browser may loop anyway for certain element
4990
+ // types (e.g. radio inputs cycle through their name group on arrow keys).
4991
+ // Return true when the browser would do something we explicitly chose not to
4992
+ // do, so the caller can preventDefault to enforce our decision.
4993
+ const browserWouldLoopWithoutPreventDefault = (element) => {
4994
+ if (element.tagName === "INPUT" && element.type === "radio") {
4995
+ // Radio: browser cycles through same-name group on arrow keys
4996
+ return true;
4997
+ }
4998
+ return false;
4999
+ };
5000
+
4551
5001
  // Handle arrow navigation inside an HTMLTableElement as a grid.
4552
5002
  // Moves focus to adjacent cell in the direction of the arrow key.
4553
- const getTargetInTableFocusGroup = (event, table, { loop }) => {
5003
+ const getTargetInTableFocusGroup = (event, table, { loop, predicate }) => {
4554
5004
  const arrowKey = event.key;
4555
5005
 
4556
5006
  // Only handle arrow keys
@@ -4568,7 +5018,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
4568
5018
 
4569
5019
  // If we're not currently in a table cell, try to focus the first focusable element in the table
4570
5020
  if (!currentCell || !table.contains(currentCell)) {
4571
- return findDescendant(table, elementIsFocusable) || null;
5021
+ return findDescendant(table, predicate) || null;
4572
5022
  }
4573
5023
 
4574
5024
  // Get the current position in the table grid
@@ -4588,6 +5038,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
4588
5038
  allRows,
4589
5039
  currentRowIndex,
4590
5040
  currentColumnIndex,
5041
+ predicate,
4591
5042
  );
4592
5043
  }
4593
5044
 
@@ -4602,7 +5053,7 @@ const getTargetInTableFocusGroup = (event, table, { loop }) => {
4602
5053
 
4603
5054
  // Find the first cell that is itself focusable
4604
5055
  for (const candidateCell of candidateCells) {
4605
- if (elementIsFocusable(candidateCell)) {
5056
+ if (predicate(candidateCell)) {
4606
5057
  return candidateCell;
4607
5058
  }
4608
5059
  }
@@ -4616,6 +5067,7 @@ const getJumpToEndTarget = (
4616
5067
  allRows,
4617
5068
  currentRowIndex,
4618
5069
  currentColumnIndex,
5070
+ predicate,
4619
5071
  ) => {
4620
5072
  if (arrowKey === "ArrowRight") {
4621
5073
  // Jump to last focusable cell in current row
@@ -4626,7 +5078,7 @@ const getJumpToEndTarget = (
4626
5078
  const cells = Array.from(currentRow.cells);
4627
5079
  for (let i = cells.length - 1; i >= 0; i--) {
4628
5080
  const cell = cells[i];
4629
- if (elementIsFocusable(cell)) {
5081
+ if (predicate(cell)) {
4630
5082
  return cell;
4631
5083
  }
4632
5084
  }
@@ -4640,7 +5092,7 @@ const getJumpToEndTarget = (
4640
5092
 
4641
5093
  const cells = Array.from(currentRow.cells);
4642
5094
  for (const cell of cells) {
4643
- if (elementIsFocusable(cell)) {
5095
+ if (predicate(cell)) {
4644
5096
  return cell;
4645
5097
  }
4646
5098
  }
@@ -4652,7 +5104,7 @@ const getJumpToEndTarget = (
4652
5104
  for (let rowIndex = allRows.length - 1; rowIndex >= 0; rowIndex--) {
4653
5105
  const row = allRows[rowIndex];
4654
5106
  const cell = row?.cells?.[currentColumnIndex];
4655
- if (cell && elementIsFocusable(cell)) {
5107
+ if (cell && predicate(cell)) {
4656
5108
  return cell;
4657
5109
  }
4658
5110
  }
@@ -4664,7 +5116,7 @@ const getJumpToEndTarget = (
4664
5116
  for (let rowIndex = 0; rowIndex < allRows.length; rowIndex++) {
4665
5117
  const row = allRows[rowIndex];
4666
5118
  const cell = row?.cells?.[currentColumnIndex];
4667
- if (cell && elementIsFocusable(cell)) {
5119
+ if (cell && predicate(cell)) {
4668
5120
  return cell;
4669
5121
  }
4670
5122
  }
@@ -4945,6 +5397,7 @@ const performTabNavigation = (
4945
5397
  rootElement = document.body,
4946
5398
  outsideOfElement = null,
4947
5399
  debug = () => {},
5400
+ excludeAriaHidden,
4948
5401
  } = {},
4949
5402
  ) => {
4950
5403
  if (!isTabEvent$1(event)) {
@@ -4967,6 +5420,12 @@ const performTabNavigation = (
4967
5420
  markFocusNav(event);
4968
5421
  targetToFocus.focus();
4969
5422
  };
5423
+ const isFocusableByTab = (element) => {
5424
+ if (hasNegativeTabIndex(element)) {
5425
+ return false;
5426
+ }
5427
+ return elementIsFocusable(element, { excludeAriaHidden });
5428
+ };
4970
5429
 
4971
5430
  const predicate = (candidate) => {
4972
5431
  const canBeFocusedByTab = isFocusableByTab(candidate);
@@ -5040,12 +5499,6 @@ const performTabNavigation = (
5040
5499
 
5041
5500
  const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
5042
5501
 
5043
- const isFocusableByTab = (element) => {
5044
- if (hasNegativeTabIndex(element)) {
5045
- return false;
5046
- }
5047
- return elementIsFocusable(element);
5048
- };
5049
5502
  const hasNegativeTabIndex = (element) => {
5050
5503
  return (
5051
5504
  element.hasAttribute &&
@@ -5064,14 +5517,47 @@ const hasNegativeTabIndex = (element) => {
5064
5517
  */
5065
5518
 
5066
5519
 
5520
+ /**
5521
+ * Initialises keyboard navigation for a focus group.
5522
+ *
5523
+ * Sets up two keyboard behaviours on the element:
5524
+ * - **Tab**: exits the group, moving focus to the next/previous focusable
5525
+ * element outside the group (standard skip-group behaviour).
5526
+ * - **Arrow keys**: moves focus between focusable descendants according to
5527
+ * the configured direction, wrapping and selector constraints.
5528
+ *
5529
+ * @param {Element} element - The focus-group root element.
5530
+ * @param {object} [options]
5531
+ * @param {boolean} [options.skipTab=true] - When true, Tab exits the group
5532
+ * instead of moving through its children one by one.
5533
+ * @param {string} [options.name] - Optional name shared between related groups
5534
+ * to enable delegation (focus jumps from one named group to another).
5535
+ * @param {boolean} [options.excludeAriaHidden=true] - Skip elements that are
5536
+ * hidden from the accessibility tree (aria-hidden).
5537
+ * @param {"x"|"y"|"both"} [options.direction="both"] - Which axes are active.
5538
+ * "x" = left/right only, "y" = up/down only, "both" = all four arrows.
5539
+ * @param {"x"|"y"|"both"} [options.wrap] - Which axes loop at boundaries.
5540
+ * Omit or pass undefined for no looping on either axis.
5541
+ * @param {string} [options.xSelector] - CSS selector that candidates must match
5542
+ * when navigating on the x axis. Omit to allow any focusable element.
5543
+ * @param {string} [options.ySelector] - CSS selector that candidates must match
5544
+ * when navigating on the y axis. Omit to allow any focusable element.
5545
+ * @returns {{ cleanup: () => void }} Call cleanup() to remove all event listeners.
5546
+ */
5067
5547
  const initFocusGroup = (
5068
5548
  element,
5069
5549
  {
5070
- direction = "both",
5071
5550
  // extend = true,
5072
5551
  skipTab = true,
5073
- loop = false,
5074
5552
  name, // Can be undefined for implicit ancestor-descendant grouping
5553
+ excludeAriaHidden = true,
5554
+ // Which axes are active: "x", "y", or "both" (default)
5555
+ direction = "both",
5556
+ // Which axes loop at boundaries: "x", "y", "both", or undefined (no looping)
5557
+ wrap,
5558
+ // CSS selector to restrict candidates on each axis
5559
+ xSelector,
5560
+ ySelector,
5075
5561
  } = {},
5076
5562
  ) => {
5077
5563
  const cleanupCallbackSet = new Set();
@@ -5085,7 +5571,6 @@ const initFocusGroup = (
5085
5571
  // Store focus group data in registry
5086
5572
  const removeFocusGroup = setFocusGroup(element, {
5087
5573
  direction,
5088
- loop,
5089
5574
  name, // Store undefined as-is for implicit grouping
5090
5575
  });
5091
5576
  cleanupCallbackSet.add(removeFocusGroup);
@@ -5099,7 +5584,10 @@ const initFocusGroup = (
5099
5584
  // Prevent double handling of the same event + allow preventing focus nav from outside
5100
5585
  return;
5101
5586
  }
5102
- performTabNavigation(event, { outsideOfElement: element });
5587
+ performTabNavigation(event, {
5588
+ outsideOfElement: element,
5589
+ excludeAriaHidden,
5590
+ });
5103
5591
  };
5104
5592
  // Handle Tab navigation (exit group)
5105
5593
  element.addEventListener("keydown", handleTabKeyDown, {
@@ -5123,7 +5611,14 @@ const initFocusGroup = (
5123
5611
  // Prevent double handling of the same event + allow preventing focus nav from outside
5124
5612
  return;
5125
5613
  }
5126
- performArrowNavigation(event, element, { direction, loop, name });
5614
+ performArrowNavigation(event, element, {
5615
+ name,
5616
+ excludeAriaHidden,
5617
+ direction,
5618
+ wrap,
5619
+ xSelector,
5620
+ ySelector,
5621
+ });
5127
5622
  };
5128
5623
  element.addEventListener("keydown", handleArrowKeyDown, {
5129
5624
  // we must use capture: false to let chance for other part of the code
@@ -5538,6 +6033,9 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
5538
6033
  }
5539
6034
  return null;
5540
6035
  }
6036
+ if (element.hasAttribute("popover") && element.matches(":popover-open")) {
6037
+ return getScrollingElement(element.ownerDocument);
6038
+ }
5541
6039
  const position = getStyle(element, "position");
5542
6040
  if (position === "fixed") {
5543
6041
  return getScrollingElement(element.ownerDocument);
@@ -6368,13 +6866,19 @@ const getPaddingSizes = (element) => {
6368
6866
  const trapScrollInside = (element) => {
6369
6867
  const cleanupCallbackSet = new Set();
6370
6868
  const lockScroll = (el) => {
6869
+ const savedScrollTop = el.scrollTop;
6870
+ const savedScrollLeft = el.scrollLeft;
6371
6871
  const scrollbarGutter = getStyle(el, "scrollbar-gutter");
6372
6872
  const hasScrollbarGutterStrategy =
6373
6873
  scrollbarGutter && scrollbarGutter !== "auto";
6374
6874
  if (hasScrollbarGutterStrategy) {
6375
6875
  // The element manages its own gutter — just hide overflow, no padding needed.
6376
6876
  const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
6377
- cleanupCallbackSet.add(removeScrollLockStyles);
6877
+ cleanupCallbackSet.add(() => {
6878
+ removeScrollLockStyles();
6879
+ el.scrollTop = savedScrollTop;
6880
+ el.scrollLeft = savedScrollLeft;
6881
+ });
6378
6882
  return;
6379
6883
  }
6380
6884
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
@@ -6384,7 +6888,11 @@ const trapScrollInside = (element) => {
6384
6888
  "padding-bottom": `${bottom + scrollbarHeight}px`,
6385
6889
  "overflow": "hidden",
6386
6890
  });
6387
- cleanupCallbackSet.add(removeScrollLockStyles);
6891
+ cleanupCallbackSet.add(() => {
6892
+ removeScrollLockStyles();
6893
+ el.scrollTop = savedScrollTop;
6894
+ el.scrollLeft = savedScrollLeft;
6895
+ });
6388
6896
  };
6389
6897
  let previous = element.previousSibling;
6390
6898
  while (previous) {
@@ -10397,23 +10905,48 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
10397
10905
  /**
10398
10906
  * Tracks how much of an element is visible within its scrollable parent and within the
10399
10907
  * document viewport. Calls update() on initialization and whenever visibility changes
10400
- * (scroll, resize, intersection changes).
10401
- *
10402
- * 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).
10908
+ * (scroll, resize, intersection changes, ancestor open/close).
10909
+ *
10910
+ * @param {HTMLElement} element - The element to observe.
10911
+ * @param {function(visibleRect: VisibleRect, info: VisibleRectInfo): void} update - Called on every visibility change.
10912
+ *
10913
+ * @typedef {Object} VisibleRect
10914
+ * @property {number} left - Left edge of the visible area, document-relative (px).
10915
+ * @property {number} top - Top edge of the visible area, document-relative (px).
10916
+ * @property {number} right - Right edge of the visible area, document-relative (px).
10917
+ * @property {number} bottom - Bottom edge of the visible area, document-relative (px).
10918
+ * @property {number} width - Width of the visible area (px).
10919
+ * @property {number} height - Height of the visible area (px).
10920
+ * @property {number} visibilityRatio - Fraction of the element's area truly visible on screen (0–1).
10921
+ * For document scroll containers: viewport-clipped fraction.
10922
+ * For custom containers: fraction clipped by both the container and the viewport.
10923
+ * Is 0 when ancestorClosed is true.
10924
+ *
10925
+ * @typedef {Object} VisibleRectInfo
10926
+ * @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
10927
+ * @property {number} width - Raw getBoundingClientRect() width of the element.
10928
+ * @property {number} height - Raw getBoundingClientRect() height of the element.
10929
+ * @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
10930
+ * currently closed so the element is not rendered. All visibleRect values are 0 in that case.
10931
+ * update() is called immediately on ancestor close and again (with false) on reopen.
10932
+ *
10933
+ * update() is called:
10934
+ * - Once synchronously on initialization (event.type = "initialization")
10935
+ * - On document/container scroll, window resize, element resize, intersection changes, touch move
10936
+ * - Immediately when an ancestor popover/dialog/details opens or closes
10409
10937
  *
10410
10938
  * A bit like https://tetherjs.dev/ but different
10411
10939
  */
10412
- const visibleRectEffect = (element, update) => {
10940
+ const visibleRectEffect = (
10941
+ element,
10942
+ update,
10943
+ { event: initialEvent = new CustomEvent("initialization") } = {},
10944
+ ) => {
10413
10945
  const [teardown, addTeardown] = createPubSub();
10414
10946
  const scrollContainer = getScrollContainer(element);
10415
10947
  const scrollContainerIsDocument =
10416
10948
  scrollContainer === document.documentElement;
10949
+ let ancestorClosedCount = 0;
10417
10950
  const check = (event) => {
10418
10951
 
10419
10952
  // 1. Calculate element position relative to scrollable parent
@@ -10550,10 +11083,11 @@ const visibleRectEffect = (element, update) => {
10550
11083
  event,
10551
11084
  width,
10552
11085
  height,
11086
+ ancestorClosed: ancestorClosedCount > 0,
10553
11087
  });
10554
11088
  };
10555
11089
 
10556
- check(new CustomEvent("initialization"));
11090
+ check(initialEvent);
10557
11091
 
10558
11092
  const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
10559
11093
  {
@@ -10683,6 +11217,59 @@ const visibleRectEffect = (element, update) => {
10683
11217
  });
10684
11218
  });
10685
11219
  }
11220
+ {
11221
+ let current = element.parentElement;
11222
+ while (current) {
11223
+ if (
11224
+ current.hasAttribute("popover") ||
11225
+ current.tagName === "DIALOG" ||
11226
+ current.tagName === "DETAILS"
11227
+ ) {
11228
+ const ancestor = current;
11229
+ const isInitiallyClosed =
11230
+ ancestor.tagName === "DIALOG" || ancestor.tagName === "DETAILS"
11231
+ ? !ancestor.open
11232
+ : !ancestor.matches(":popover-open");
11233
+ if (isInitiallyClosed) {
11234
+ ancestorClosedCount++;
11235
+ }
11236
+ // eslint-disable-next-line no-loop-func
11237
+ const onToggle = (e) => {
11238
+ const isClosed =
11239
+ ancestor.tagName === "DETAILS"
11240
+ ? !ancestor.open
11241
+ : e.newState === "closed";
11242
+ if (isClosed) {
11243
+ ancestorClosedCount++;
11244
+ update(
11245
+ {
11246
+ left: 0,
11247
+ top: 0,
11248
+ right: 0,
11249
+ bottom: 0,
11250
+ width: 0,
11251
+ height: 0,
11252
+ visibilityRatio: 0,
11253
+ },
11254
+ { event: e, width: 0, height: 0, ancestorClosed: true },
11255
+ );
11256
+ } else {
11257
+ if (ancestorClosedCount > 0) {
11258
+ ancestorClosedCount--;
11259
+ }
11260
+ if (ancestorClosedCount === 0) {
11261
+ check(e);
11262
+ }
11263
+ }
11264
+ };
11265
+ ancestor.addEventListener("toggle", onToggle);
11266
+ addTeardown(() => {
11267
+ ancestor.removeEventListener("toggle", onToggle);
11268
+ });
11269
+ }
11270
+ current = current.parentElement;
11271
+ }
11272
+ }
10686
11273
  }
10687
11274
 
10688
11275
  return {
@@ -10744,6 +11331,7 @@ const pickPositionRelativeTo = (
10744
11331
  alignToViewportEdgeWhenAnchorNearEdge = 0,
10745
11332
  minLeft = 0,
10746
11333
  spacing = 0,
11334
+ alignToAnchorBox = "border-box",
10747
11335
  viewportSpacing = 0,
10748
11336
  } = {},
10749
11337
  ) => {
@@ -10768,10 +11356,30 @@ const pickPositionRelativeTo = (
10768
11356
  const anchorWidth = anchorRight - anchorLeft;
10769
11357
  const anchorHeight = anchorBottom - anchorTop;
10770
11358
 
10771
- const spaceAbove = anchorTop;
10772
- const spaceBelow = viewportHeight - anchorBottom;
10773
- const spaceLeft = anchorLeft;
10774
- 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;
10775
11383
 
10776
11384
  // Resolve active X and Y, and whether each is fixed (no flip fallback)
10777
11385
  let activeX;
@@ -10845,7 +11453,11 @@ const pickPositionRelativeTo = (
10845
11453
  if (currentFitsEnough) {
10846
11454
  finalY = activeY;
10847
11455
  } else {
10848
- 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;
10849
11461
  }
10850
11462
  }
10851
11463
  }
@@ -10908,44 +11520,49 @@ const pickPositionRelativeTo = (
10908
11520
  let elementPositionLeft;
10909
11521
  {
10910
11522
  if (finalX === "to-the-left") {
10911
- elementPositionLeft = anchorLeft - elementWidth - spacing;
11523
+ elementPositionLeft = effectiveAnchorLeft - elementWidth - spacing;
10912
11524
  } else if (finalX === "left-aligned") {
10913
- elementPositionLeft = anchorLeft;
11525
+ elementPositionLeft = effectiveAnchorLeft;
10914
11526
  } else if (finalX === "center") {
10915
11527
  // Complex logic handles wide anchors and viewport-edge snapping
10916
11528
  const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
10917
11529
  if (anchorIsWiderThanViewport) {
10918
- const anchorLeftIsVisible = anchorLeft >= 0;
10919
- const anchorRightIsVisible = anchorRight <= viewportWidth;
11530
+ const anchorLeftIsVisible = effectiveAnchorLeft >= 0;
11531
+ const anchorRightIsVisible = effectiveAnchorRight <= viewportWidth;
10920
11532
  if (!anchorLeftIsVisible && anchorRightIsVisible) {
10921
11533
  const viewportCenter = viewportWidth / 2;
10922
- const distanceFromRightEdge = viewportWidth - anchorRight;
11534
+ const distanceFromRightEdge = viewportWidth - effectiveAnchorRight;
10923
11535
  elementPositionLeft =
10924
11536
  viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
10925
11537
  } else if (anchorLeftIsVisible && !anchorRightIsVisible) {
10926
11538
  const viewportCenter = viewportWidth / 2;
10927
- const distanceFromLeftEdge = -anchorLeft;
11539
+ const distanceFromLeftEdge = -effectiveAnchorLeft;
10928
11540
  elementPositionLeft =
10929
11541
  viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
10930
11542
  } else {
10931
11543
  elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
10932
11544
  }
10933
11545
  } else {
10934
- elementPositionLeft = anchorLeft + anchorWidth / 2 - elementWidth / 2;
11546
+ elementPositionLeft =
11547
+ effectiveAnchorLeft +
11548
+ (effectiveAnchorRight - effectiveAnchorLeft) / 2 -
11549
+ elementWidth / 2;
10935
11550
  if (alignToViewportEdgeWhenAnchorNearEdge) {
10936
- const elementIsWiderThanAnchor = elementWidth > anchorWidth;
11551
+ const effectiveAnchorWidth =
11552
+ effectiveAnchorRight - effectiveAnchorLeft;
11553
+ const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
10937
11554
  const anchorIsNearLeftEdge =
10938
- anchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
11555
+ effectiveAnchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
10939
11556
  if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
10940
11557
  elementPositionLeft = minLeft;
10941
11558
  }
10942
11559
  }
10943
11560
  }
10944
11561
  } else if (finalX === "right-aligned") {
10945
- elementPositionLeft = anchorRight - elementWidth;
11562
+ elementPositionLeft = effectiveAnchorRight - elementWidth;
10946
11563
  } else {
10947
11564
  // "to-the-right"
10948
- elementPositionLeft = anchorRight + spacing;
11565
+ elementPositionLeft = effectiveAnchorRight + spacing;
10949
11566
  }
10950
11567
  // Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
10951
11568
  if (elementPositionLeft < viewportSpacing) {
@@ -10962,8 +11579,8 @@ const pickPositionRelativeTo = (
10962
11579
  let elementPositionTop;
10963
11580
  {
10964
11581
  if (finalY === "above") {
10965
- // top is always anchorTop - elementHeight - spacing — max-height truncates if needed.
10966
- const idealTop = anchorTop - elementHeight - spacing;
11582
+ // top is always anchorTop + insetTop - elementHeight - spacing — max-height truncates if needed.
11583
+ const idealTop = anchorTop + insetTop - elementHeight - spacing;
10967
11584
  elementPositionTop =
10968
11585
  idealTop < viewportSpacing ? viewportSpacing : idealTop;
10969
11586
  } else if (finalY === "above-overlap") {
@@ -10978,9 +11595,9 @@ const pickPositionRelativeTo = (
10978
11595
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10979
11596
  } else {
10980
11597
  // "below"
10981
- // top is always anchorBottom + spacing — max-height (via --space-available) truncates
11598
+ // top is always anchorBottom - insetBottom + spacing — max-height (via --space-available) truncates
10982
11599
  // the element height so it doesn't overflow the viewport bottom.
10983
- const idealTop = anchorBottom + spacing;
11600
+ const idealTop = anchorBottom - insetBottom + spacing;
10984
11601
  elementPositionTop =
10985
11602
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10986
11603
  }
@@ -14031,6 +14648,144 @@ const getMaxWidth = (
14031
14648
  return maxWidth;
14032
14649
  };
14033
14650
 
14651
+ /**
14652
+ * Measures the width of the longest rendered visual line inside an element.
14653
+ *
14654
+ * Useful for solving the CSS "shrinkwrap" problem: when multi-line text sits
14655
+ * inside a `max-width` container, CSS expands the element to fill all
14656
+ * available space, leaving trailing whitespace to the right of the text.
14657
+ * Setting an explicit width equal to the longest line eliminates that gap.
14658
+ * See shrinkwrap_demo.html for a visual explanation.
14659
+ *
14660
+ * Returns `null` when all content fits on a single visual line (nothing to
14661
+ * optimize). Returns the pixel width of the widest line when text wraps to
14662
+ * two or more lines.
14663
+ *
14664
+ * ## Implementation note — bounding extent, not sum of widths
14665
+ *
14666
+ * `range.getClientRects()` returns one rect per layout box intersecting the
14667
+ * range. Nested elements (e.g. `<span><span>text</span></span>`) produce
14668
+ * multiple overlapping rects for the exact same pixels on the same line.
14669
+ * Summing their `width` values therefore over-counts the true line width.
14670
+ *
14671
+ * Instead we compute the bounding extent per line: track the minimum `left`
14672
+ * and maximum `right` across all rects sharing the same rounded `top`, then
14673
+ * use `right - left` as the line width. This is correct regardless of nesting
14674
+ * depth and works well for regular inline text content.
14675
+ *
14676
+ * Limitation: rects are grouped by `Math.round(r.top)`, so elements on the
14677
+ * same visual line but with slightly different baselines (e.g. an icon taller
14678
+ * than surrounding text) could be counted as separate lines. This is unlikely
14679
+ * to matter in practice for normal text rendering.
14680
+ *
14681
+ * Limitation: `range.getClientRects()` returns rects for text nodes and inline
14682
+ * boxes as laid out in the flow, ignoring any `overflow: hidden` or `max-width`
14683
+ * clipping applied to ancestor elements. If child elements clip their own
14684
+ * content (e.g. badges with `overflow: hidden` and `max-width`), the rects
14685
+ * will reflect the unclipped text size, producing a width larger than what is
14686
+ * visually rendered. In that case prefer `measureWidestChildRow`, which uses
14687
+ * each child's own `getBoundingClientRect()` and therefore respects clipping.
14688
+ *
14689
+ * @param {Element} el - The element whose text content should be measured.
14690
+ * @returns {number|null} Width in pixels of the longest visual line,
14691
+ * or `null` if there is only one visual line.
14692
+ */
14693
+ const measureLongestVisualLineWidth = (el) => {
14694
+ const range = document.createRange();
14695
+ range.selectNodeContents(el);
14696
+
14697
+ const lineBoundsByTop = new Map();
14698
+ for (const r of range.getClientRects()) {
14699
+ if (r.width === 0) {
14700
+ continue;
14701
+ }
14702
+ const top = Math.round(r.top);
14703
+ const existing = lineBoundsByTop.get(top);
14704
+ if (existing === undefined) {
14705
+ lineBoundsByTop.set(top, { left: r.left, right: r.right });
14706
+ } else {
14707
+ if (r.left < existing.left) {
14708
+ existing.left = r.left;
14709
+ }
14710
+ if (r.right > existing.right) {
14711
+ existing.right = r.right;
14712
+ }
14713
+ }
14714
+ }
14715
+
14716
+ if (lineBoundsByTop.size <= 1) {
14717
+ return null;
14718
+ }
14719
+
14720
+ let longestLineWidth = 0;
14721
+ for (const { left, right } of lineBoundsByTop.values()) {
14722
+ const w = right - left;
14723
+ if (w > longestLineWidth) {
14724
+ longestLineWidth = w;
14725
+ }
14726
+ }
14727
+ return longestLineWidth;
14728
+ };
14729
+
14730
+ // Measures the width of the widest row of direct children.
14731
+ // Uses children's bounding rects (which respect overflow:hidden / max-width)
14732
+ // rather than Range.getClientRects() which sees through clipping boundaries.
14733
+ // Returns null when all children fit on a single row (nothing to optimize).
14734
+ const measureWidestChildRow = (el) => {
14735
+ const children = Array.from(el.children);
14736
+ if (children.length === 0) {
14737
+ return null;
14738
+ }
14739
+
14740
+ const containerStyle = getComputedStyle(el);
14741
+ const paddingLeft = parseFloat(containerStyle.paddingLeft);
14742
+ const paddingRight = parseFloat(containerStyle.paddingRight);
14743
+ const borderLeft = parseFloat(containerStyle.borderLeftWidth);
14744
+ const borderRight = parseFloat(containerStyle.borderRightWidth);
14745
+
14746
+ // Group children by row using their top position
14747
+ const rowsByTop = new Map();
14748
+ for (const child of children) {
14749
+ const rect = child.getBoundingClientRect();
14750
+ if (rect.width === 0 && rect.height === 0) {
14751
+ continue;
14752
+ }
14753
+ const top = Math.round(rect.top);
14754
+ const existing = rowsByTop.get(top);
14755
+ if (existing === undefined) {
14756
+ rowsByTop.set(top, { left: rect.left, right: rect.right });
14757
+ } else {
14758
+ if (rect.left < existing.left) {
14759
+ existing.left = rect.left;
14760
+ }
14761
+ if (rect.right > existing.right) {
14762
+ existing.right = rect.right;
14763
+ }
14764
+ }
14765
+ }
14766
+
14767
+ if (rowsByTop.size <= 1) {
14768
+ return null;
14769
+ }
14770
+
14771
+ let widestRowWidth = 0;
14772
+ for (const { left, right } of rowsByTop.values()) {
14773
+ const rowWidth = right - left;
14774
+ if (rowWidth > widestRowWidth) {
14775
+ widestRowWidth = rowWidth;
14776
+ }
14777
+ }
14778
+
14779
+ // Convert from absolute pixel width to the container's content-box width
14780
+ // so that setting el.style.width = result + "px" works correctly.
14781
+ if (containerStyle.boxSizing === "border-box") {
14782
+ return (
14783
+ widestRowWidth + paddingLeft + paddingRight + borderLeft + borderRight
14784
+ );
14785
+ }
14786
+ return widestRowWidth;
14787
+ };
14788
+
14034
14789
  const useAvailableHeight = (elementRef) => {
14035
14790
  const [availableHeight, availableHeightSetter] = useState(-1);
14036
14791
 
@@ -14147,4 +14902,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
14147
14902
  };
14148
14903
  };
14149
14904
 
14150
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, 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 };
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 };