@lumx/react 4.16.0-next.0 → 4.16.1-alpha.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 (3) hide show
  1. package/index.js +312 -309
  2. package/index.js.map +1 -1
  3. package/package.json +4 -7
package/index.js CHANGED
@@ -5325,15 +5325,25 @@ Tooltip.displayName = COMPONENT_NAME$1i;
5325
5325
  Tooltip.className = CLASSNAME$1h;
5326
5326
  Tooltip.defaultProps = DEFAULT_PROPS$12;
5327
5327
 
5328
- /**
5329
- * Transition the active item: deactivate the current one (if any) and activate the new one.
5330
- * Reads the current active item via `getActiveItem` so there is no internal state to desync.
5331
- */
5332
- function transition(getActiveItem, callbacks, newItem) {
5333
- const current = getActiveItem();
5334
- if (current === newItem) return;
5335
- if (current) callbacks.onDeactivate(current);
5336
- callbacks.onActivate(newItem);
5328
+ /** Create a pending navigation store; discards intent on abort. */
5329
+ function createPendingNavigation(signal) {
5330
+ let pending = null;
5331
+ const clear = () => {
5332
+ pending = null;
5333
+ };
5334
+ signal.addEventListener('abort', clear);
5335
+ return {
5336
+ get hasPending() {
5337
+ return pending !== null;
5338
+ },
5339
+ defer(navigate) {
5340
+ pending = navigate;
5341
+ },
5342
+ flush() {
5343
+ if (pending?.()) pending = null;
5344
+ },
5345
+ clear
5346
+ };
5337
5347
  }
5338
5348
 
5339
5349
  /**
@@ -5362,10 +5372,7 @@ function createListFocusNavigation(options, callbacks, signal) {
5362
5372
  /** Combined CSS selector matching enabled (non-disabled) items. */
5363
5373
  const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
5364
5374
 
5365
- /**
5366
- * Create a TreeWalker over items in the container.
5367
- * @param enabledOnly When true (default), disabled items are skipped.
5368
- */
5375
+ /** Create a TreeWalker over items in the container. */
5369
5376
  function createItemWalker(enabledOnly = true) {
5370
5377
  const selector = enabledOnly ? enabledItemSelector : itemSelector;
5371
5378
  return createSelectorTreeWalker(container, selector);
@@ -5382,110 +5389,76 @@ function createListFocusNavigation(options, callbacks, signal) {
5382
5389
  return items.length > 0 ? items[items.length - 1] : null;
5383
5390
  }
5384
5391
 
5385
- /** Navigate to the first enabled item and activate it. */
5386
- function goToFirst() {
5387
- const first = findFirstEnabled();
5388
- if (!first) return false;
5389
- transition(getActiveItem, callbacks, first);
5390
- return true;
5391
- }
5392
+ // Deferred navigation intent (replayed once items are committed to the DOM)
5393
+ const pending = createPendingNavigation(signal);
5392
5394
 
5393
- /** Navigate to the last enabled item and activate it. */
5394
- function goToLast() {
5395
- const last = findLastEnabled();
5396
- if (!last) return false;
5397
- transition(getActiveItem, callbacks, last);
5398
- return true;
5399
- }
5400
- function navigateByOffset(offset) {
5401
- const active = getActiveItem();
5402
- if (offset === 0) return active !== null;
5395
+ /** Find item at offset (lazily walk nodes) */
5396
+ function findAtOffset(offset) {
5403
5397
  const forward = offset > 0;
5404
5398
  const stepsNeeded = Math.abs(offset);
5405
-
5406
- // No active item — fall back to first/last.
5407
- if (!active) {
5408
- const started = forward ? goToFirst() : goToLast();
5409
- if (!started) return false;
5410
- if (stepsNeeded === 1) return true;
5411
- return navigateByOffset(forward ? offset - 1 : offset + 1);
5412
- }
5413
-
5414
- // Walk from the active item using a TreeWalker.
5399
+ const active = getActiveItem();
5415
5400
  const walker = createItemWalker();
5416
- walker.currentNode = active;
5417
5401
  const step = forward ? () => walker.nextNode() : () => walker.previousNode();
5418
- let stepsCompleted = 0;
5419
- let lastFound = null;
5420
- for (let i = 0; i < stepsNeeded; i++) {
5402
+ let target = null;
5403
+ let remaining = stepsNeeded;
5404
+ if (active) {
5405
+ // Walk from the active item.
5406
+ walker.currentNode = active;
5407
+ } else if (!forward) {
5408
+ // Walking backward with no active item: position at the last enabled item
5409
+ target = walker.lastChild();
5410
+ if (!target) return null;
5411
+ remaining -= 1;
5412
+ }
5413
+ for (let i = 0; i < remaining; i++) {
5421
5414
  const next = step();
5422
5415
  if (next) {
5423
- lastFound = next;
5424
- stepsCompleted += 1;
5425
- } else if (wrap) {
5426
- // Hit boundary — wrap around to the opposite end.
5416
+ target = next;
5417
+ } else if (active && wrap) {
5418
+ // Hit boundary with an active item — wrap around to the opposite end.
5427
5419
  const wrapped = forward ? findFirstEnabled() : findLastEnabled();
5428
5420
  if (!wrapped || wrapped === active) break;
5429
- lastFound = wrapped;
5430
- stepsCompleted += 1;
5421
+ target = wrapped;
5431
5422
  walker.currentNode = wrapped;
5432
5423
  } else {
5433
5424
  break;
5434
5425
  }
5435
5426
  }
5436
- if (stepsCompleted === 0) return false;
5437
- transition(getActiveItem, callbacks, lastFound);
5438
- return true;
5427
+ return target;
5428
+ }
5429
+ function findMatching(predicate) {
5430
+ const walker = createItemWalker(false);
5431
+ let node = walker.nextNode();
5432
+ while (node) {
5433
+ if (predicate(node)) return node;
5434
+ node = walker.nextNode();
5435
+ }
5436
+ return null;
5439
5437
  }
5440
- const navigateForward = () => navigateByOffset(1);
5441
- const navigateBackward = () => navigateByOffset(-1);
5442
5438
 
5443
- /** Clear the active item. */
5439
+ /** Clear the active item and discard any pending navigation intent. */
5444
5440
  function clear() {
5445
5441
  const current = getActiveItem();
5446
5442
  if (current) {
5447
5443
  callbacks.onDeactivate(current);
5448
5444
  }
5445
+ pending.clear();
5449
5446
  callbacks.onClear?.();
5450
5447
  }
5451
5448
 
5452
5449
  // Cleanup on abort.
5453
5450
  signal.addEventListener('abort', clear);
5454
- return {
5455
- type: 'list',
5451
+ const selectors = {
5456
5452
  enabledItemSelector,
5457
5453
  get activeItem() {
5458
5454
  return getActiveItem();
5459
5455
  },
5460
- get hasActiveItem() {
5461
- return getActiveItem() !== null;
5462
- },
5463
5456
  get hasNavigableItems() {
5464
5457
  return container.querySelector(enabledItemSelector) !== null;
5465
5458
  },
5466
- goToFirst,
5467
- goToLast,
5468
- goToItem(item) {
5469
- if (!item.matches(itemSelector)) return false;
5470
- if (!container.contains(item)) return false;
5471
- transition(getActiveItem, callbacks, item);
5472
- return true;
5473
- },
5474
- goToOffset(offset) {
5475
- return navigateByOffset(offset);
5476
- },
5477
- goToItemMatching(predicate) {
5478
- const walker = createItemWalker(false);
5479
- let node = walker.nextNode();
5480
- while (node) {
5481
- if (predicate(node)) {
5482
- transition(getActiveItem, callbacks, node);
5483
- return true;
5484
- }
5485
- node = walker.nextNode();
5486
- }
5487
- return false;
5488
- },
5459
+ getFirst: findFirstEnabled,
5460
+ getLast: findLastEnabled,
5461
+ getMatching: findMatching,
5489
5462
  findNearestEnabled(anchor) {
5490
5463
  if (!container.contains(anchor)) return findFirstEnabled();
5491
5464
 
@@ -5503,19 +5476,57 @@ function createListFocusNavigation(options, callbacks, signal) {
5503
5476
  // No enabled item after anchor — walk backward (reuse same walker).
5504
5477
  walker.currentNode = anchor;
5505
5478
  return walker.previousNode();
5506
- },
5479
+ }
5480
+ };
5481
+
5482
+ /**
5483
+ * Navigate via a resolver. The resolver receives the selectors to find the target.
5484
+ * If the target is valid, focus is committed (deactivate current, activate target).
5485
+ * If the target is not resolvable yet, the intent is deferred for later replay.
5486
+ */
5487
+ function goTo(resolve) {
5488
+ const target = resolve(selectors);
5489
+ if (target && target.matches(itemSelector) && container.contains(target)) {
5490
+ const current = getActiveItem();
5491
+ if (current !== target) {
5492
+ if (current) callbacks.onDeactivate(current);
5493
+ callbacks.onActivate(target);
5494
+ }
5495
+ pending.clear();
5496
+ return true;
5497
+ }
5498
+ // Target not resolvable yet (e.g. items not committed to the DOM) — defer
5499
+ pending.defer(() => goTo(resolve));
5500
+ return false;
5501
+ }
5502
+
5503
+ /** Go to the item at the given offset from the active item. */
5504
+ function goToOffset(offset) {
5505
+ if (offset === 0) return getActiveItem() !== null;
5506
+ const target = findAtOffset(offset);
5507
+ if (!target) return false;
5508
+ return goTo(() => target);
5509
+ }
5510
+ return {
5511
+ type: 'list',
5512
+ selectors,
5513
+ goToOffset,
5507
5514
  clear,
5515
+ goTo,
5516
+ flushPendingNavigation() {
5517
+ pending.flush();
5518
+ },
5508
5519
  goUp() {
5509
- return direction === 'vertical' ? navigateBackward() : false;
5520
+ return direction === 'vertical' ? goToOffset(-1) : false;
5510
5521
  },
5511
5522
  goDown() {
5512
- return direction === 'vertical' ? navigateForward() : false;
5523
+ return direction === 'vertical' ? goToOffset(1) : false;
5513
5524
  },
5514
5525
  goLeft() {
5515
- return direction === 'horizontal' ? navigateBackward() : false;
5526
+ return direction === 'horizontal' ? goToOffset(-1) : false;
5516
5527
  },
5517
5528
  goRight() {
5518
- return direction === 'horizontal' ? navigateForward() : false;
5529
+ return direction === 'horizontal' ? goToOffset(1) : false;
5519
5530
  }
5520
5531
  };
5521
5532
  }
@@ -5565,13 +5576,32 @@ function createActiveItemState(callbacks, signal, initialItem) {
5565
5576
  };
5566
5577
  }
5567
5578
 
5579
+ /** Deepest last-child descendant of `el` (or `el` itself if it has no children). */
5580
+ function lastDescendant(element) {
5581
+ let node = element;
5582
+ while (node.lastElementChild) node = node.lastElementChild;
5583
+ return node;
5584
+ }
5585
+
5586
+ /** Return the first item of an iterable, or `null` if it is empty. */
5587
+ function first(iterable) {
5588
+ const {
5589
+ value,
5590
+ done
5591
+ } = iterable[Symbol.iterator]().next();
5592
+ return done ? null : value;
5593
+ }
5594
+
5568
5595
  /**
5569
5596
  * Create a focus navigation controller for a 2D grid.
5570
5597
  *
5598
+ * Resolves rows and cells by querying the DOM and commits focus by updating the
5599
+ * active-item state (which fires {@link FocusNavigationCallbacks}).
5600
+ *
5571
5601
  * Supports Up/Down between rows (with column memory) and Left/Right between cells
5572
5602
  * (with wrapping across rows).
5573
5603
  *
5574
- * @param options Grid navigation options (container, rowSelector, cellSelector, isRowVisible, wrap).
5604
+ * @param options Grid navigation options (container, rowSelector, cellSelector, wrap).
5575
5605
  * @param callbacks Callbacks for focus state changes.
5576
5606
  * @param signal AbortSignal for cleanup.
5577
5607
  * @returns FocusNavigationController instance.
@@ -5581,89 +5611,46 @@ function createGridFocusNavigation(options, callbacks, signal) {
5581
5611
  container,
5582
5612
  rowSelector,
5583
5613
  cellSelector,
5584
- isRowVisible,
5585
5614
  wrap = false
5586
5615
  } = options;
5587
5616
  const state = createActiveItemState(callbacks, signal);
5588
- /** Remembered column index for Up/Down navigation (column memory). */
5589
- let rememberedCol = 0;
5590
-
5591
- /** Check if a row element is visible (passes the isRowVisible filter). */
5592
- function isVisible(row) {
5593
- return !isRowVisible || isRowVisible(row);
5594
- }
5595
-
5596
- /** Check if a row is navigable (visible with at least one cell). */
5597
- function isNavigableRow(row) {
5598
- return isVisible(row) && row.querySelector(cellSelector) !== null;
5599
- }
5600
-
5601
- /** Create a TreeWalker scoped to row elements within the container. */
5602
- function createRowWalker() {
5603
- return createSelectorTreeWalker(container, rowSelector);
5604
- }
5605
-
5606
- /**
5607
- * Iterate navigable rows from the container using a TreeWalker.
5608
- * - `'first'`: returns the first navigable row (or null).
5609
- * - `'last'`: returns the last navigable row (or null).
5610
- * - `'all'`: returns all navigable rows as an array.
5611
- */
5612
-
5613
- function findVisibleRows(mode) {
5614
- const walker = createRowWalker();
5615
- if (mode === 'all') {
5616
- const result = [];
5617
- let node = walker.nextNode();
5618
- while (node) {
5619
- if (isNavigableRow(node)) result.push(node);
5620
- node = walker.nextNode();
5621
- }
5622
- return result;
5623
- }
5624
- let found = null;
5625
- let node = walker.nextNode();
5626
- while (node) {
5627
- if (isNavigableRow(node)) {
5628
- if (mode === 'first') return node;
5629
- found = node;
5617
+ const isNavigableRow = row => row.querySelector(cellSelector) !== null;
5618
+ const getFirstCell = row => row.querySelector(cellSelector);
5619
+ const getRowCells = row => Array.from(row.querySelectorAll(cellSelector));
5620
+
5621
+ /** Lazily walk navigable rows from `start` in a `direction`, projecting each row through `dive`. */
5622
+ function* findRows(start = 'first', direction = 'next', dive) {
5623
+ // Tree walker
5624
+ const walker = createSelectorTreeWalker(container, rowSelector);
5625
+
5626
+ // Start at a specific row
5627
+ if (start instanceof HTMLElement) walker.currentNode = start;
5628
+ // Start from the last
5629
+ else if (start === 'last') walker.currentNode = lastDescendant(container);
5630
+
5631
+ // Walk nodes
5632
+ let node;
5633
+ do {
5634
+ node = direction === 'next' ? walker.nextNode() : walker.previousNode();
5635
+ if (node && isNavigableRow(node)) {
5636
+ const result = dive ? dive(node) : node;
5637
+ if (result) yield result;
5630
5638
  }
5631
- node = walker.nextNode();
5632
- }
5633
- return found;
5634
- }
5635
-
5636
- /** Get the cells within a single row element. */
5637
- function getRowCells(row) {
5638
- return Array.from(row.querySelectorAll(cellSelector));
5639
+ } while (node);
5639
5640
  }
5640
-
5641
- /** Find the row element containing a cell, using closest(). */
5641
+ const findFirstVisibleRow = () => first(findRows());
5642
+ const findLastVisibleRow = () => first(findRows('last', 'prev'));
5642
5643
  function findParentRow(cell) {
5643
5644
  const row = cell.closest(rowSelector);
5644
5645
  return row && container.contains(row) ? row : null;
5645
5646
  }
5646
5647
 
5647
- /**
5648
- * Walk to the next or previous visible row (with cells) from a given row
5649
- * using a TreeWalker. Avoids building the full visible rows array.
5650
- */
5651
- function findAdjacentVisibleRow(fromRow, direction) {
5652
- const walker = createRowWalker();
5653
- walker.currentNode = fromRow;
5654
- const advance = direction === 'next' ? () => walker.nextNode() : () => walker.previousNode();
5655
- let node = advance();
5656
- while (node) {
5657
- if (isNavigableRow(node)) return node;
5658
- node = advance();
5659
- }
5660
- return null;
5661
- }
5648
+ /** Deferred navigation intent (replayed once cells are committed to the DOM). */
5649
+ const pending = createPendingNavigation(signal);
5650
+ /** Remembered column index for Up/Down navigation (column memory). */
5651
+ let rememberedCol = 0;
5662
5652
 
5663
- /**
5664
- * Activate the cell at the given column in a row element.
5665
- * Clamps col to the row's available cells.
5666
- */
5653
+ /** Activate the cell at the given column in a row element. */
5667
5654
  function focusCellInRow(row, col) {
5668
5655
  const cells = getRowCells(row);
5669
5656
  if (cells.length === 0) return false;
@@ -5671,14 +5658,30 @@ function createGridFocusNavigation(options, callbacks, signal) {
5671
5658
  state.setActive(cells[clampedCol]);
5672
5659
  return true;
5673
5660
  }
5661
+
5662
+ /** Activate the given cell (validates it is in the grid, updates column memory). */
5663
+ function activateCell(item) {
5664
+ const row = findParentRow(item);
5665
+ if (!row) return false;
5666
+ const cells = getRowCells(row);
5667
+ const col = cells.indexOf(item);
5668
+ if (col === -1) return false;
5669
+ rememberedCol = col;
5670
+ state.setActive(item);
5671
+ return true;
5672
+ }
5673
+
5674
+ /** Got to first cell in first row */
5674
5675
  function goToFirst() {
5675
- const row = findVisibleRows('first');
5676
+ const row = findFirstVisibleRow();
5676
5677
  if (!row) return false;
5677
5678
  rememberedCol = 0;
5678
5679
  return focusCellInRow(row, 0);
5679
5680
  }
5681
+
5682
+ /** Got to first cell in last row */
5680
5683
  function goToLast() {
5681
- const row = findVisibleRows('last');
5684
+ const row = findLastVisibleRow();
5682
5685
  if (!row) return false;
5683
5686
  rememberedCol = 0;
5684
5687
  return focusCellInRow(row, 0);
@@ -5702,8 +5705,8 @@ function createGridFocusNavigation(options, callbacks, signal) {
5702
5705
 
5703
5706
  // Wrap to the adjacent row (or opposite boundary row), activating the first or last cell.
5704
5707
  const rowDirection = step > 0 ? 'next' : 'prev';
5705
- const adjacentRow = findAdjacentVisibleRow(currentRow, rowDirection);
5706
- const targetRow = adjacentRow ?? (step > 0 ? findVisibleRows('first') : findVisibleRows('last'));
5708
+ const adjacentRow = first(findRows(currentRow, rowDirection));
5709
+ const targetRow = adjacentRow ?? (step > 0 ? findFirstVisibleRow() : findLastVisibleRow());
5707
5710
  if (!targetRow) return false;
5708
5711
  const targetCells = getRowCells(targetRow);
5709
5712
  if (targetCells.length === 0) return false;
@@ -5721,31 +5724,35 @@ function createGridFocusNavigation(options, callbacks, signal) {
5721
5724
  }
5722
5725
  const currentRow = findParentRow(state.active);
5723
5726
  if (!currentRow) return false;
5724
- const adjacentRow = findAdjacentVisibleRow(currentRow, direction);
5727
+ const adjacentRow = first(findRows(currentRow, direction));
5725
5728
  if (adjacentRow) return focusCellInRow(adjacentRow, rememberedCol);
5726
5729
  if (wrap) {
5727
5730
  // Wrap to the opposite boundary row.
5728
- const wrapRow = direction === 'next' ? findVisibleRows('first') : findVisibleRows('last');
5731
+ const wrapRow = direction === 'next' ? findFirstVisibleRow() : findLastVisibleRow();
5729
5732
  if (wrapRow) return focusCellInRow(wrapRow, rememberedCol);
5730
5733
  }
5731
5734
  return false;
5732
5735
  }
5733
- return {
5734
- type: 'grid',
5736
+ const selectors = {
5735
5737
  get activeItem() {
5736
5738
  return state.active;
5737
5739
  },
5738
- get hasActiveItem() {
5739
- return state.active !== null;
5740
- },
5741
5740
  get hasNavigableItems() {
5742
- return findVisibleRows('first') !== null;
5741
+ return first(findRows()) !== null;
5743
5742
  },
5744
- goToFirst,
5745
- goToLast,
5743
+ // First cell in first row
5744
+ getFirst: () => first(findRows('first', 'next', getFirstCell)),
5745
+ // First cell in last row
5746
+ getLast: () => first(findRows('last', 'prev', getFirstCell)),
5747
+ // First cell matching predicate
5748
+ getMatching: predicate => first(findRows('first', 'next', row => getRowCells(row).find(predicate)))
5749
+ };
5750
+ return {
5751
+ type: 'grid',
5752
+ selectors,
5746
5753
  goToOffset(offset) {
5747
5754
  if (offset === 0) return state.active !== null;
5748
- const visibleRows = findVisibleRows('all');
5755
+ const visibleRows = Array.from(findRows());
5749
5756
  if (visibleRows.length === 0) return false;
5750
5757
  if (!state.active) {
5751
5758
  // No active item: jump to first or last row, then apply remaining offset.
@@ -5764,38 +5771,22 @@ function createGridFocusNavigation(options, callbacks, signal) {
5764
5771
  if (targetIdx === rowIdx) return false;
5765
5772
  return focusCellInRow(visibleRows[targetIdx], rememberedCol);
5766
5773
  },
5767
- goToItemMatching(predicate) {
5768
- // Iterate visible rows lazily — short-circuit on first match.
5769
- const walker = createRowWalker();
5770
- let row = walker.nextNode();
5771
- while (row) {
5772
- if (isNavigableRow(row)) {
5773
- const cells = getRowCells(row);
5774
- for (let c = 0; c < cells.length; c++) {
5775
- if (predicate(cells[c])) {
5776
- rememberedCol = c;
5777
- state.setActive(cells[c]);
5778
- return true;
5779
- }
5780
- }
5781
- }
5782
- row = walker.nextNode();
5774
+ clear() {
5775
+ state.clear();
5776
+ pending.clear();
5777
+ },
5778
+ goTo(resolve) {
5779
+ const target = resolve(selectors);
5780
+ if (target && activateCell(target)) {
5781
+ pending.clear();
5782
+ return true;
5783
5783
  }
5784
+ // Target not resolvable yet (e.g. cells not committed to the DOM) — defer
5785
+ pending.defer(() => this.goTo(resolve));
5784
5786
  return false;
5785
5787
  },
5786
- goToItem(item) {
5787
- // Use closest() to find the parent row, then find col within that row only.
5788
- const row = findParentRow(item);
5789
- if (!row || !isVisible(row)) return false;
5790
- const cells = getRowCells(row);
5791
- const col = cells.indexOf(item);
5792
- if (col === -1) return false;
5793
- rememberedCol = col;
5794
- state.setActive(item);
5795
- return true;
5796
- },
5797
- clear() {
5798
- state.clear();
5788
+ flushPendingNavigation() {
5789
+ pending.flush();
5799
5790
  },
5800
5791
  goUp() {
5801
5792
  return goVertical('prev');
@@ -5931,7 +5922,7 @@ function setupRovingTabIndex(options, signal) {
5931
5922
  }
5932
5923
  }
5933
5924
  if (!hasTabStop) {
5934
- const fallback = container.querySelector(nav.enabledItemSelector);
5925
+ const fallback = container.querySelector(nav.selectors.enabledItemSelector);
5935
5926
  if (fallback) setTabIndex(fallback, '0');
5936
5927
  }
5937
5928
  }
@@ -5941,7 +5932,7 @@ function setupRovingTabIndex(options, signal) {
5941
5932
  const items = Array.from(container.querySelectorAll(itemSelector));
5942
5933
  const {
5943
5934
  activeItem
5944
- } = nav;
5935
+ } = nav.selectors;
5945
5936
 
5946
5937
  // Prefer either the current active item (from DOM) or the current selected item
5947
5938
  let preferredTabStopIndex = activeItem && items.indexOf(activeItem);
@@ -5958,10 +5949,10 @@ function setupRovingTabIndex(options, signal) {
5958
5949
  /** Ensure a tab stop exists; find a fallback near `anchor` and optionally move focus to it. */
5959
5950
  function ensureTabStop(shouldFocus, anchor) {
5960
5951
  if (container.querySelector(itemActiveSelector)) return;
5961
- const fallback = (anchor && nav.findNearestEnabled(anchor)) ?? container.querySelector(nav.enabledItemSelector);
5952
+ const fallback = (anchor && nav.selectors.findNearestEnabled(anchor)) ?? container.querySelector(nav.selectors.enabledItemSelector);
5962
5953
  if (!fallback) return;
5963
5954
  if (shouldFocus) {
5964
- nav.goToItem(fallback);
5955
+ nav.goTo(() => fallback);
5965
5956
  } else {
5966
5957
  setTabIndex(fallback, '0');
5967
5958
  }
@@ -6031,7 +6022,7 @@ function setupRovingTabIndex(options, signal) {
6031
6022
 
6032
6023
  // Handle disabled
6033
6024
  if (disabledTargets.length > 0) {
6034
- const currentActive = nav.activeItem;
6025
+ const currentActive = nav.selectors.activeItem;
6035
6026
  for (const target of disabledTargets) {
6036
6027
  setTabIndex(target, '-1');
6037
6028
  }
@@ -6063,10 +6054,10 @@ function setupRovingTabIndex(options, signal) {
6063
6054
 
6064
6055
  container.addEventListener('keydown', evt => {
6065
6056
  // Adopt focus target if nothing is active yet.
6066
- if (!nav.activeItem) {
6057
+ if (!nav.selectors.activeItem) {
6067
6058
  const target = evt.target;
6068
6059
  if (target.matches(itemSelector) && container.contains(target)) {
6069
- nav.goToItem(target);
6060
+ nav.goTo(() => target);
6070
6061
  }
6071
6062
  }
6072
6063
  let handled = false;
@@ -6084,10 +6075,10 @@ function setupRovingTabIndex(options, signal) {
6084
6075
  handled = nav.goUp();
6085
6076
  break;
6086
6077
  case 'Home':
6087
- handled = nav.goToFirst();
6078
+ handled = nav.goTo(s => s.getFirst());
6088
6079
  break;
6089
6080
  case 'End':
6090
- handled = nav.goToLast();
6081
+ handled = nav.goTo(s => s.getLast());
6091
6082
  break;
6092
6083
  }
6093
6084
  if (handled) {
@@ -6248,12 +6239,24 @@ SelectionChipGroup.className = CLASSNAME$1j;
6248
6239
  /**
6249
6240
  * Get the value for a combobox option element.
6250
6241
  * Uses `data-value` when set; falls back to the element's trimmed `textContent`.
6242
+ *
6243
+ * This is the *selection* value , which may differ from the visible label
6251
6244
  */
6252
6245
  function getOptionValue(option) {
6253
6246
  if (option.dataset.value !== undefined) return option.dataset.value;
6254
6247
  return option.textContent?.trim() ?? '';
6255
6248
  }
6256
6249
 
6250
+ /**
6251
+ * Get the visible label for a combobox option element (its trimmed `textContent`).
6252
+ *
6253
+ * Used for typeahead matching: the user types the characters they see, which is the
6254
+ * option's label — not its `data-value` (which can be an unrelated id).
6255
+ */
6256
+ function getOptionLabel(option) {
6257
+ return option.textContent?.trim() ?? '';
6258
+ }
6259
+
6257
6260
  /** Returns true when an option carries aria-disabled="true". */
6258
6261
  function isOptionDisabled(option) {
6259
6262
  return option.getAttribute('aria-disabled') === 'true';
@@ -6265,20 +6268,8 @@ function isActionCell(cell) {
6265
6268
  if (!row) return false;
6266
6269
  return row.querySelector('[role="gridcell"]') !== cell;
6267
6270
  }
6268
-
6269
- /** Predicate matching an option element that carries `aria-selected="true"`. */
6270
6271
  const isSelected = el => el.getAttribute('aria-selected') === 'true';
6271
6272
 
6272
- /** Navigate to the selected option, or to the first option if none is selected. */
6273
- function goToSelectedOrFirst(nav) {
6274
- if (!nav.goToItemMatching(isSelected)) nav.goToFirst();
6275
- }
6276
-
6277
- /** Navigate to the selected option, or to the last option if none is selected. */
6278
- function goToSelectedOrLast(nav) {
6279
- if (!nav.goToItemMatching(isSelected)) nav.goToLast();
6280
- }
6281
-
6282
6273
  /**
6283
6274
  * Compute the current state of a section and notify when it changed.
6284
6275
  *
@@ -6330,7 +6321,7 @@ function notifySection(sectionElement, sectionRegistrations, optionRegistrations
6330
6321
  * @param notify Notify subscribers of combobox events.
6331
6322
  * @returns The created focus navigation controller.
6332
6323
  */
6333
- function setupListbox(handle, signal, notify) {
6324
+ function setupListbox(handle, signal, notify, options) {
6334
6325
  const trigger = handle.trigger;
6335
6326
  const listbox = handle.listbox;
6336
6327
  const isGrid = listbox.getAttribute('role') === 'grid';
@@ -6383,6 +6374,7 @@ function setupListbox(handle, signal, notify) {
6383
6374
  focusNav = createListFocusNavigation({
6384
6375
  container: listbox,
6385
6376
  itemSelector,
6377
+ wrap: options?.wrapNavigation,
6386
6378
  getActiveItem: () => {
6387
6379
  const id = trigger.getAttribute('aria-activedescendant');
6388
6380
  return id ? document.getElementById(id) : null;
@@ -6591,12 +6583,12 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6591
6583
  const nav = handle.focusNav;
6592
6584
  switch (event.key) {
6593
6585
  case 'Enter':
6594
- if (handle.isOpen && nav?.hasActiveItem && nav.activeItem) {
6586
+ if (handle.isOpen && nav?.selectors.activeItem) {
6595
6587
  // Capture activeItem before click — the click handler may close
6596
6588
  // the popover and clear the focus navigation state.
6597
6589
  const {
6598
6590
  activeItem
6599
- } = nav;
6591
+ } = nav.selectors;
6600
6592
  // "Click" on active option
6601
6593
  if (!isOptionDisabled(activeItem)) {
6602
6594
  activeItem.click();
@@ -6614,40 +6606,38 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6614
6606
  // Otherwise (closed popup, or multi-select with no active item),
6615
6607
  // let Enter pass through so it can submit a surrounding form
6616
6608
  break;
6609
+
6610
+ // Open if closed, else move focus within listbox (wrap if enabled).
6617
6611
  case 'ArrowDown':
6618
- if (nav?.hasNavigableItems) {
6619
- if (handle.isOpen && !altKey) {
6620
- if (nav.hasActiveItem) {
6621
- if (nav.type === 'grid') {
6622
- nav.goDown();
6623
- } else if (!nav.goToOffset(1) && wrapNavigation) {
6624
- nav.goToFirst();
6625
- }
6626
- } else {
6627
- goToSelectedOrFirst(nav);
6628
- }
6612
+ if (!handle.isOpen) {
6613
+ handle.setIsOpen(true);
6614
+ // Focus first or selected item on open.
6615
+ if (!altKey) nav?.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6616
+ } else if (nav?.selectors.hasNavigableItems && !altKey) {
6617
+ if (nav.selectors.activeItem) {
6618
+ // Go down
6619
+ nav.goDown();
6629
6620
  } else {
6630
- // Open the listbox and focus selected option, fall back to first.
6631
- handle.setIsOpen(true);
6632
- if (!altKey) goToSelectedOrFirst(nav);
6621
+ // Focus first or selected item when no active item.
6622
+ nav.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6633
6623
  }
6634
6624
  }
6635
6625
  flag = true;
6636
6626
  break;
6627
+
6628
+ // Open if closed, else move focus within listbox (wrap if enabled).
6637
6629
  case 'ArrowUp':
6638
- if (nav?.hasNavigableItems) {
6639
- if (!handle.isOpen && !altKey) {
6640
- // Open the listbox and focus selected option, fall back to last.
6641
- handle.setIsOpen(true);
6642
- goToSelectedOrLast(nav);
6643
- } else if (handle.isOpen && nav.hasActiveItem) {
6644
- if (nav.type === 'grid') {
6645
- nav.goUp();
6646
- } else if (!nav.goToOffset(-1) && wrapNavigation) {
6647
- nav.goToLast();
6648
- }
6649
- } else if (handle.isOpen && !nav.hasActiveItem && !altKey) {
6650
- goToSelectedOrLast(nav);
6630
+ if (!handle.isOpen && !altKey) {
6631
+ handle.setIsOpen(true);
6632
+ // Focus last or selected item on open.
6633
+ nav?.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6634
+ } else if (handle.isOpen && nav?.selectors.hasNavigableItems) {
6635
+ if (nav.selectors.activeItem) {
6636
+ // Go up
6637
+ nav.goUp();
6638
+ } else if (!altKey) {
6639
+ // Focus last or selected item when no active item.
6640
+ nav.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6651
6641
  }
6652
6642
  }
6653
6643
  flag = true;
@@ -6662,13 +6652,13 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6662
6652
  flag = true;
6663
6653
  break;
6664
6654
  case 'PageUp':
6665
- if (handle.isOpen && nav?.hasActiveItem) {
6655
+ if (handle.isOpen && nav?.selectors.activeItem) {
6666
6656
  nav.goToOffset(-10);
6667
6657
  }
6668
6658
  flag = true;
6669
6659
  break;
6670
6660
  case 'PageDown':
6671
- if (handle.isOpen && nav?.hasActiveItem) {
6661
+ if (handle.isOpen && nav?.selectors.activeItem) {
6672
6662
  nav.goToOffset(10);
6673
6663
  }
6674
6664
  flag = true;
@@ -6713,7 +6703,9 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6713
6703
  });
6714
6704
  }
6715
6705
  if (listbox && !focusNav) {
6716
- focusNav = setupListbox(handle, abortController.signal, notify);
6706
+ focusNav = setupListbox(handle, abortController.signal, notify, {
6707
+ wrapNavigation
6708
+ });
6717
6709
  }
6718
6710
  }
6719
6711
  handle = {
@@ -6754,25 +6746,16 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6754
6746
  // Update aria-expanded on trigger
6755
6747
  trigger?.setAttribute('aria-expanded', String(isOpen));
6756
6748
  notify('open', isOpen);
6757
-
6758
- // When opening, always notify the current options state so that
6759
- // subscribers (ComboboxState) get the correct initial value.
6760
- // Without this, a list that starts empty never fires `optionsChange`
6761
- // because `lastOptionsLength` is initialized to `0` and `notifyVisibilityChange`
6762
- // only fires on *changes*.
6763
- if (isOpen) {
6764
- const inputValue = trigger?.value ?? '';
6765
- notify('optionsChange', {
6766
- optionsLength: lastOptionsLength,
6767
- inputValue
6768
- });
6769
- }
6770
6749
  },
6771
6750
  select(option) {
6772
6751
  callbacks.onSelect?.({
6773
6752
  value: option ? getOptionValue(option) : ''
6774
6753
  });
6775
6754
  },
6755
+ flushPendingNavigation() {
6756
+ // Do navigations actions we could not do because the combobox items were not mounted yet
6757
+ focusNav?.flushPendingNavigation();
6758
+ },
6776
6759
  registerOption(element, callback) {
6777
6760
  const filterLower = filterValue.toLowerCase();
6778
6761
  const text = getOptionValue(element).toLowerCase();
@@ -6867,7 +6850,9 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6867
6850
  if (trigger && abortController) {
6868
6851
  if (!hadListbox) {
6869
6852
  // First listbox — set up focus nav and listbox listeners
6870
- focusNav = setupListbox(handle, abortController.signal, notify);
6853
+ focusNav = setupListbox(handle, abortController.signal, notify, {
6854
+ wrapNavigation
6855
+ });
6871
6856
  } else {
6872
6857
  // Replacing listbox — full re-attach
6873
6858
  detach();
@@ -6945,18 +6930,9 @@ function createTypeahead(getWalker, getItemValue, signal) {
6945
6930
  }
6946
6931
  signal.addEventListener('abort', reset);
6947
6932
 
6948
- // Handle typeahead keys
6949
- function handle(key, currentItem) {
6950
- // Clear any pending reset timeout.
6951
- if (searchTimeout !== undefined) {
6952
- clearTimeout(searchTimeout);
6953
- }
6954
-
6955
- // Accumulate the character.
6956
- searchString += key.toLowerCase();
6957
-
6958
- // Schedule clearing the search string after inactivity.
6959
- searchTimeout = setTimeout(reset, SEARCH_TIMEOUT);
6933
+ // Match the current accumulated search string against the live DOM.
6934
+ function match(currentItem) {
6935
+ if (!searchString) return null;
6960
6936
  const walker = getWalker();
6961
6937
  if (!walker) return null;
6962
6938
 
@@ -7017,8 +6993,29 @@ function createTypeahead(getWalker, getItemValue, signal) {
7017
6993
  }
7018
6994
  }
7019
6995
  }
6996
+
6997
+ // Handle typeahead keys
6998
+ function handle(key, currentItem) {
6999
+ // Clear any pending reset timeout.
7000
+ if (searchTimeout !== undefined) {
7001
+ clearTimeout(searchTimeout);
7002
+ }
7003
+
7004
+ // Accumulate the character.
7005
+ searchString += key.toLowerCase();
7006
+
7007
+ // Schedule clearing the search string after inactivity.
7008
+ searchTimeout = setTimeout(reset, SEARCH_TIMEOUT);
7009
+ return match(currentItem);
7010
+ }
7011
+
7012
+ // Re-run the match for the current buffer without mutating it.
7013
+ function rematch(currentItem) {
7014
+ return match(currentItem);
7015
+ }
7020
7016
  return {
7021
7017
  handle,
7018
+ rematch,
7022
7019
  reset
7023
7020
  };
7024
7021
  }
@@ -7055,7 +7052,7 @@ function setupComboboxButton(button, callbacks) {
7055
7052
  // In list mode, walk option elements.
7056
7053
  const selector = combobox.focusNav?.type === 'grid' ? '[role="gridcell"]' : '[role="option"]';
7057
7054
  return createSelectorTreeWalker(combobox.listbox, selector);
7058
- }, getOptionValue, signal);
7055
+ }, getOptionLabel, signal);
7059
7056
 
7060
7057
  // Click toggles the listbox.
7061
7058
  button.addEventListener('click', () => combobox.setIsOpen(!combobox.isOpen), {
@@ -7066,47 +7063,49 @@ function setupComboboxButton(button, callbacks) {
7066
7063
  switch (event.key) {
7067
7064
  case 'Tab':
7068
7065
  // Selects the focused option
7069
- if (combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7070
- combobox.select(nav.activeItem);
7066
+ if (combobox.isOpen && nav?.selectors.activeItem) {
7067
+ combobox.select(nav.selectors.activeItem);
7071
7068
  }
7072
7069
  // Return false to continue normal 'Tab' behavior (focus next).
7073
7070
  return false;
7074
7071
  case ' ':
7075
7072
  // Space acts like Enter in button mode.
7076
- if (combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7073
+ if (combobox.isOpen && nav?.selectors.activeItem) {
7077
7074
  // Click the active item — delegated handler handles select + close.
7078
- nav.activeItem.click();
7075
+ nav.selectors.activeItem.click();
7079
7076
  } else {
7080
7077
  combobox.setIsOpen(true);
7081
7078
  }
7082
7079
  return true;
7083
7080
  case 'ArrowUp':
7084
7081
  // Alt+ArrowUp: select the focused option and close.
7085
- if (event.altKey && combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7086
- combobox.select(nav.activeItem);
7082
+ if (event.altKey && combobox.isOpen && nav?.selectors.activeItem) {
7083
+ combobox.select(nav.selectors.activeItem);
7087
7084
  combobox.setIsOpen(false);
7088
7085
  return true;
7089
7086
  }
7090
7087
  // All other ArrowUp cases handled by base handler.
7091
7088
  return false;
7092
7089
  case 'Home':
7090
+ // `goTo` focuses the first option immediately when open, or defers
7091
+ // until the options commit when opening from closed.
7093
7092
  combobox.setIsOpen(true);
7094
- nav?.goToFirst();
7093
+ nav?.goTo(n => n.getFirst());
7095
7094
  return true;
7096
7095
  case 'End':
7097
7096
  combobox.setIsOpen(true);
7098
- nav?.goToLast();
7097
+ nav?.goTo(n => n.getLast());
7099
7098
  return true;
7100
7099
  case 'ArrowLeft':
7101
7100
  // Grid mode: navigate to previous cell.
7102
- if (nav?.type === 'grid' && combobox.isOpen && nav.hasActiveItem) {
7101
+ if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
7103
7102
  nav.goLeft();
7104
7103
  return true;
7105
7104
  }
7106
7105
  return false;
7107
7106
  case 'ArrowRight':
7108
7107
  // Grid mode: navigate to next cell.
7109
- if (nav?.type === 'grid' && combobox.isOpen && nav.hasActiveItem) {
7108
+ if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
7110
7109
  nav.goRight();
7111
7110
  return true;
7112
7111
  }
@@ -7121,10 +7120,8 @@ function setupComboboxButton(button, callbacks) {
7121
7120
  // Printable characters → typeahead.
7122
7121
  if (isPrintableKey(event)) {
7123
7122
  combobox.setIsOpen(true);
7124
- const match = typeahead.handle(event.key, nav?.activeItem ?? null);
7125
- if (match && nav) {
7126
- nav.goToItem(match);
7127
- }
7123
+ typeahead.handle(event.key, nav?.selectors.activeItem ?? null);
7124
+ nav?.goTo(n => typeahead.rematch(n.activeItem));
7128
7125
  return true;
7129
7126
  }
7130
7127
  return false;
@@ -7448,7 +7445,7 @@ function setupComboboxInput(input, options) {
7448
7445
  case 'ArrowLeft':
7449
7446
  case 'ArrowRight':
7450
7447
  // Grid mode: navigate cells when active item exists.
7451
- if (nav?.type === 'grid' && nav.hasActiveItem) {
7448
+ if (nav?.type === 'grid' && nav.selectors.activeItem) {
7452
7449
  if (event.key === 'ArrowLeft') nav.goLeft();else nav.goRight();
7453
7450
  return true;
7454
7451
  }
@@ -8307,25 +8304,30 @@ const ComboboxList = forwardRef((props, ref) => {
8307
8304
  const listContextValue = useMemo(() => ({
8308
8305
  type
8309
8306
  }), [type]);
8307
+ const [isOpen] = useComboboxOpen();
8308
+ const options = useComboboxEvent('optionsChange', undefined);
8309
+ const visibleCount = options?.optionsLength ?? 0;
8310
8310
 
8311
- // Register the list as the listbox when the handle becomes available
8311
+ // Register list as listbox when handle is available.
8312
8312
  useEffect(() => {
8313
8313
  const list = internalRef.current;
8314
8314
  if (!list) return undefined;
8315
8315
  return handle?.registerListbox(list);
8316
8316
  }, [handle]);
8317
8317
 
8318
- // Track loading state for aria-busy (auto-derived from skeleton registrations).
8319
- // Uses both the handle's synchronous getter (for initial state) and the loadingChange event
8320
- // (for subsequent updates), because child useEffects (skeleton registration) run before
8321
- // parent subscriptions — relying on events alone would miss the initial notification.
8318
+ // Track loading state for aria-busy
8322
8319
  const [isLoading, setIsLoading] = useState(false);
8323
8320
  useEffect(() => {
8324
8321
  if (!handle) return undefined;
8325
- // Read current state synchronously (catches registrations that happened before subscription)
8322
+ // Read current state synchronously (catches registrations before subscription).
8326
8323
  setIsLoading(handle.isLoading);
8327
8324
  return handle.subscribe('loadingChange', setIsLoading);
8328
8325
  }, [handle]);
8326
+
8327
+ // Flush pending keyboard navigation after options commit on open.
8328
+ useEffect(() => {
8329
+ if (isOpen) handle?.flushPendingNavigation();
8330
+ }, [isOpen, visibleCount, handle]);
8329
8331
  return /*#__PURE__*/jsx(ComboboxListContext.Provider, {
8330
8332
  value: listContextValue,
8331
8333
  children: ComboboxList$1({
@@ -8336,7 +8338,7 @@ const ComboboxList = forwardRef((props, ref) => {
8336
8338
  ref: mergedRef,
8337
8339
  id: listboxId,
8338
8340
  type,
8339
- children
8341
+ children: isOpen ? children : null
8340
8342
  })
8341
8343
  });
8342
8344
  });
@@ -9163,6 +9165,7 @@ const Popover$1 = (props, {
9163
9165
  [`position-${position}`]: Boolean(position),
9164
9166
  'is-hidden': Boolean(isHidden)
9165
9167
  })),
9168
+ hidden: isHidden || undefined,
9166
9169
  style: isHidden ? undefined : popoverStyle,
9167
9170
  "data-popper-placement": position,
9168
9171
  children: [unmountSentinel, /*#__PURE__*/jsxs(ClickAwayProvider, {