@lumx/react 4.16.0-alpha.4 → 4.16.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -5325,6 +5325,111 @@ Tooltip.displayName = COMPONENT_NAME$1i;
5325
5325
  Tooltip.className = CLASSNAME$1h;
5326
5326
  Tooltip.defaultProps = DEFAULT_PROPS$12;
5327
5327
 
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
+ };
5347
+ }
5348
+
5349
+ /**
5350
+ * Create the pure selection layer for a 1D list.
5351
+ *
5352
+ * Everything here is side-effect-free: it only *queries* the DOM to resolve and report
5353
+ * items. No focus is moved and no {@link FocusNavigationCallbacks} are invoked — that is
5354
+ * the job of the mover layer built on top of this (`createListFocusNavigation`).
5355
+ *
5356
+ * @param options List navigation options (container, itemSelector, itemDisabledSelector, getActiveItem).
5357
+ * @returns The public {@link ListFocusNavigationSelectors} plus the internal
5358
+ * {@link ListSelectorHelpers} the mover layer consumes.
5359
+ */
5360
+ function createListSelectors(options) {
5361
+ const {
5362
+ container,
5363
+ itemSelector,
5364
+ itemDisabledSelector,
5365
+ getActiveItem = () => null
5366
+ } = options;
5367
+
5368
+ /** Combined CSS selector matching enabled (non-disabled) items. */
5369
+ const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
5370
+ function createItemWalker(enabledOnly = true) {
5371
+ const selector = enabledOnly ? enabledItemSelector : itemSelector;
5372
+ return createSelectorTreeWalker(container, selector);
5373
+ }
5374
+ function findFirstEnabled() {
5375
+ return container.querySelector(enabledItemSelector);
5376
+ }
5377
+ function findLastEnabled() {
5378
+ const items = container.querySelectorAll(enabledItemSelector);
5379
+ return items.length > 0 ? items[items.length - 1] : null;
5380
+ }
5381
+ function findMatching(predicate) {
5382
+ const walker = createItemWalker(false);
5383
+ let node = walker.nextNode();
5384
+ while (node) {
5385
+ if (predicate(node)) return node;
5386
+ node = walker.nextNode();
5387
+ }
5388
+ return null;
5389
+ }
5390
+ function findNearestEnabled(anchor) {
5391
+ if (!container.contains(anchor)) return findFirstEnabled();
5392
+
5393
+ // If the anchor itself is an enabled item, return it directly.
5394
+ if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
5395
+ return anchor;
5396
+ }
5397
+
5398
+ // Walk forward from the anchor for the nearest enabled item.
5399
+ const walker = createItemWalker();
5400
+ walker.currentNode = anchor;
5401
+ const next = walker.nextNode();
5402
+ if (next instanceof HTMLElement) return next;
5403
+
5404
+ // No enabled item after anchor — walk backward (reuse same walker).
5405
+ walker.currentNode = anchor;
5406
+ return walker.previousNode();
5407
+ }
5408
+ const selectors = {
5409
+ enabledItemSelector,
5410
+ get activeItem() {
5411
+ return getActiveItem();
5412
+ },
5413
+ get hasNavigableItems() {
5414
+ return container.querySelector(enabledItemSelector) !== null;
5415
+ },
5416
+ getFirst: findFirstEnabled,
5417
+ getLast: findLastEnabled,
5418
+ getMatching: findMatching,
5419
+ findNearestEnabled
5420
+ };
5421
+ const helpers = {
5422
+ createItemWalker,
5423
+ findFirstEnabled,
5424
+ findLastEnabled,
5425
+ getActiveItem
5426
+ };
5427
+ return {
5428
+ selectors,
5429
+ helpers
5430
+ };
5431
+ }
5432
+
5328
5433
  /**
5329
5434
  * Transition the active item: deactivate the current one (if any) and activate the new one.
5330
5435
  * Reads the current active item via `getActiveItem` so there is no internal state to desync.
@@ -5339,6 +5444,12 @@ function transition(getActiveItem, callbacks, newItem) {
5339
5444
  /**
5340
5445
  * Create a focus navigation controller for a 1D list.
5341
5446
  *
5447
+ * The controller is composed of two layers:
5448
+ * - a pure, side-effect-free **selection** layer ({@link createListSelectors}) that only
5449
+ * resolves/reports items by querying the DOM, and
5450
+ * - a **mover** layer (this function) that commits focus by calling
5451
+ * {@link FocusNavigationCallbacks} on top of the selectors.
5452
+ *
5342
5453
  * This controller is **stateless** — it does not maintain an internal reference to
5343
5454
  * the active item. Instead it reads the active item from the DOM each time via the
5344
5455
  * `getActiveItem` callback provided in the options. This avoids any desync between
@@ -5351,36 +5462,24 @@ function transition(getActiveItem, callbacks, newItem) {
5351
5462
  */
5352
5463
  function createListFocusNavigation(options, callbacks, signal) {
5353
5464
  const {
5354
- container,
5355
5465
  itemSelector,
5466
+ container,
5356
5467
  direction = 'vertical',
5357
- wrap = false,
5358
- itemDisabledSelector,
5359
- getActiveItem = () => null
5468
+ wrap = false
5360
5469
  } = options;
5470
+ const {
5471
+ selectors,
5472
+ helpers
5473
+ } = createListSelectors(options);
5474
+ const {
5475
+ getActiveItem,
5476
+ createItemWalker,
5477
+ findFirstEnabled,
5478
+ findLastEnabled
5479
+ } = helpers;
5361
5480
 
5362
- /** Combined CSS selector matching enabled (non-disabled) items. */
5363
- const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
5364
-
5365
- /**
5366
- * Create a TreeWalker over items in the container.
5367
- * @param enabledOnly When true (default), disabled items are skipped.
5368
- */
5369
- function createItemWalker(enabledOnly = true) {
5370
- const selector = enabledOnly ? enabledItemSelector : itemSelector;
5371
- return createSelectorTreeWalker(container, selector);
5372
- }
5373
-
5374
- /** Find the first enabled item in the container. */
5375
- function findFirstEnabled() {
5376
- return container.querySelector(enabledItemSelector);
5377
- }
5378
-
5379
- /** Find the last enabled item in the container. */
5380
- function findLastEnabled() {
5381
- const items = container.querySelectorAll(enabledItemSelector);
5382
- return items.length > 0 ? items[items.length - 1] : null;
5383
- }
5481
+ // Deferred navigation intent (replayed once items are committed to the DOM)
5482
+ const pending = createPendingNavigation(signal);
5384
5483
 
5385
5484
  /** Navigate to the first enabled item and activate it. */
5386
5485
  function goToFirst() {
@@ -5397,7 +5496,9 @@ function createListFocusNavigation(options, callbacks, signal) {
5397
5496
  transition(getActiveItem, callbacks, last);
5398
5497
  return true;
5399
5498
  }
5400
- function navigateByOffset(offset) {
5499
+
5500
+ /** Go to item at an offset */
5501
+ function goToOffset(offset) {
5401
5502
  const active = getActiveItem();
5402
5503
  if (offset === 0) return active !== null;
5403
5504
  const forward = offset > 0;
@@ -5408,7 +5509,7 @@ function createListFocusNavigation(options, callbacks, signal) {
5408
5509
  const started = forward ? goToFirst() : goToLast();
5409
5510
  if (!started) return false;
5410
5511
  if (stepsNeeded === 1) return true;
5411
- return navigateByOffset(forward ? offset - 1 : offset + 1);
5512
+ return goToOffset(forward ? offset - 1 : offset + 1);
5412
5513
  }
5413
5514
 
5414
5515
  // Walk from the active item using a TreeWalker.
@@ -5437,15 +5538,14 @@ function createListFocusNavigation(options, callbacks, signal) {
5437
5538
  transition(getActiveItem, callbacks, lastFound);
5438
5539
  return true;
5439
5540
  }
5440
- const navigateForward = () => navigateByOffset(1);
5441
- const navigateBackward = () => navigateByOffset(-1);
5442
5541
 
5443
- /** Clear the active item. */
5542
+ /** Clear the active item and discard any pending navigation intent. */
5444
5543
  function clear() {
5445
5544
  const current = getActiveItem();
5446
5545
  if (current) {
5447
5546
  callbacks.onDeactivate(current);
5448
5547
  }
5548
+ pending.clear();
5449
5549
  callbacks.onClear?.();
5450
5550
  }
5451
5551
 
@@ -5453,69 +5553,40 @@ function createListFocusNavigation(options, callbacks, signal) {
5453
5553
  signal.addEventListener('abort', clear);
5454
5554
  return {
5455
5555
  type: 'list',
5456
- enabledItemSelector,
5457
- get activeItem() {
5458
- return getActiveItem();
5459
- },
5460
- get hasActiveItem() {
5461
- return getActiveItem() !== null;
5462
- },
5463
- get hasNavigableItems() {
5464
- return container.querySelector(enabledItemSelector) !== null;
5465
- },
5466
- goToFirst,
5467
- goToLast,
5556
+ selectors,
5468
5557
  goToItem(item) {
5469
5558
  if (!item.matches(itemSelector)) return false;
5470
5559
  if (!container.contains(item)) return false;
5471
5560
  transition(getActiveItem, callbacks, item);
5472
5561
  return true;
5473
5562
  },
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();
5563
+ goToOffset,
5564
+ clear,
5565
+ goTo(resolve) {
5566
+ const target = resolve(selectors);
5567
+ if (target && target.matches(itemSelector) && container.contains(target)) {
5568
+ transition(getActiveItem, callbacks, target);
5569
+ pending.clear();
5570
+ return true;
5486
5571
  }
5572
+ // Target not resolvable yet (e.g. items not committed to the DOM) — defer
5573
+ pending.defer(() => this.goTo(resolve));
5487
5574
  return false;
5488
5575
  },
5489
- findNearestEnabled(anchor) {
5490
- if (!container.contains(anchor)) return findFirstEnabled();
5491
-
5492
- // If the anchor itself is an enabled item, return it directly.
5493
- if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
5494
- return anchor;
5495
- }
5496
-
5497
- // Walk forward from the anchor for the nearest enabled item.
5498
- const walker = createItemWalker();
5499
- walker.currentNode = anchor;
5500
- const next = walker.nextNode();
5501
- if (next instanceof HTMLElement) return next;
5502
-
5503
- // No enabled item after anchor — walk backward (reuse same walker).
5504
- walker.currentNode = anchor;
5505
- return walker.previousNode();
5576
+ flushPendingNavigation() {
5577
+ pending.flush();
5506
5578
  },
5507
- clear,
5508
5579
  goUp() {
5509
- return direction === 'vertical' ? navigateBackward() : false;
5580
+ return direction === 'vertical' ? goToOffset(-1) : false;
5510
5581
  },
5511
5582
  goDown() {
5512
- return direction === 'vertical' ? navigateForward() : false;
5583
+ return direction === 'vertical' ? goToOffset(1) : false;
5513
5584
  },
5514
5585
  goLeft() {
5515
- return direction === 'horizontal' ? navigateBackward() : false;
5586
+ return direction === 'horizontal' ? goToOffset(-1) : false;
5516
5587
  },
5517
5588
  goRight() {
5518
- return direction === 'horizontal' ? navigateForward() : false;
5589
+ return direction === 'horizontal' ? goToOffset(1) : false;
5519
5590
  }
5520
5591
  };
5521
5592
  }
@@ -5538,7 +5609,7 @@ function createListFocusNavigation(options, callbacks, signal) {
5538
5609
  * @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
5539
5610
  */
5540
5611
  function createActiveItemState(callbacks, signal, initialItem) {
5541
- let activeItem = initialItem ?? null;
5612
+ let activeItem = null;
5542
5613
  function clear() {
5543
5614
  if (activeItem) {
5544
5615
  callbacks.onDeactivate(activeItem);
@@ -5565,105 +5636,165 @@ function createActiveItemState(callbacks, signal, initialItem) {
5565
5636
  };
5566
5637
  }
5567
5638
 
5639
+ /** Deepest last-child descendant of `el` (or `el` itself if it has no children). */
5640
+ function lastDescendant(element) {
5641
+ let node = element;
5642
+ while (node.lastElementChild) node = node.lastElementChild;
5643
+ return node;
5644
+ }
5645
+
5646
+ /** Return the first item of an iterable, or `null` if it is empty. */
5647
+ function first(iterable) {
5648
+ const {
5649
+ value,
5650
+ done
5651
+ } = iterable[Symbol.iterator]().next();
5652
+ return done ? null : value;
5653
+ }
5654
+
5568
5655
  /**
5569
- * Create a focus navigation controller for a 2D grid.
5656
+ * Create the pure selection layer for a 2D grid.
5570
5657
  *
5571
- * Supports Up/Down between rows (with column memory) and Left/Right between cells
5572
- * (with wrapping across rows).
5658
+ * Everything here is side-effect-free: it only *queries* the DOM to resolve and report
5659
+ * rows/cells. No focus is moved and no {@link FocusNavigationCallbacks} are invoked — that
5660
+ * is the job of the mover layer built on top of this.
5573
5661
  *
5574
- * @param options Grid navigation options (container, rowSelector, cellSelector, isRowVisible, wrap).
5575
- * @param callbacks Callbacks for focus state changes.
5576
- * @param signal AbortSignal for cleanup.
5577
- * @returns FocusNavigationController instance.
5662
+ * @param options Grid navigation options (container, rowSelector, cellSelector).
5663
+ * @param getActive Reader for the currently active cell (owned by the mover's active-item state).
5664
+ * @param isVisible Predicate deciding whether a row is visible (derived from `isRowVisible`).
5665
+ * @returns The public {@link FocusNavigationSelectors} plus the internal
5666
+ * {@link GridSelectorHelpers} the mover layer consumes.
5578
5667
  */
5579
- function createGridFocusNavigation(options, callbacks, signal) {
5668
+ function createGridSelectors(options, getActive, isVisible) {
5580
5669
  const {
5581
5670
  container,
5582
5671
  rowSelector,
5583
- cellSelector,
5584
- isRowVisible,
5585
- wrap = false
5672
+ cellSelector
5586
5673
  } = options;
5587
- 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
5674
  function isNavigableRow(row) {
5598
5675
  return isVisible(row) && row.querySelector(cellSelector) !== null;
5599
5676
  }
5600
5677
 
5601
- /** Create a TreeWalker scoped to row elements within the container. */
5602
- function createRowWalker() {
5603
- return createSelectorTreeWalker(container, rowSelector);
5604
- }
5605
-
5606
5678
  /**
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.
5679
+ * Lazily walk navigable rows (visible, with cells) starting from `startNode` in
5680
+ * `direction`, projecting each row through `dive`.
5611
5681
  */
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();
5682
+ function* findRow(direction, startNode = null, dive) {
5683
+ const walker = createSelectorTreeWalker(container, rowSelector);
5684
+ if (startNode) walker.currentNode = startNode;
5685
+ const advance = direction === 'next' ? () => walker.nextNode() : () => walker.previousNode();
5686
+ let node = advance();
5626
5687
  while (node) {
5627
5688
  if (isNavigableRow(node)) {
5628
- if (mode === 'first') return node;
5629
- found = node;
5689
+ const result = dive ? dive(node) : node;
5690
+ if (result) yield result;
5630
5691
  }
5631
- node = walker.nextNode();
5692
+ node = advance();
5632
5693
  }
5633
- return found;
5634
5694
  }
5635
5695
 
5636
- /** Get the cells within a single row element. */
5696
+ /** Find the first or last navigable row (visible, with cells), or null. */
5697
+ function findVisibleRow(mode) {
5698
+ return mode === 'first' ? first(findRow('next')) : first(findRow('prev', lastDescendant(container)));
5699
+ }
5700
+
5701
+ /** Collect all navigable rows (visible, with cells) in DOM order. */
5702
+ function findAllVisibleRows() {
5703
+ return Array.from(findRow('next'));
5704
+ }
5637
5705
  function getRowCells(row) {
5638
5706
  return Array.from(row.querySelectorAll(cellSelector));
5639
5707
  }
5640
-
5641
- /** Find the row element containing a cell, using closest(). */
5642
5708
  function findParentRow(cell) {
5643
5709
  const row = cell.closest(rowSelector);
5644
5710
  return row && container.contains(row) ? row : null;
5645
5711
  }
5646
-
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
5712
  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;
5713
+ return first(findRow(direction, fromRow));
5661
5714
  }
5662
5715
 
5663
- /**
5664
- * Activate the cell at the given column in a row element.
5665
- * Clamps col to the row's available cells.
5666
- */
5716
+ /** Resolve the first cell of the boundary row (without moving focus). */
5717
+ function getBoundaryCell(mode) {
5718
+ const row = findVisibleRow(mode);
5719
+ if (!row) return null;
5720
+ const cells = getRowCells(row);
5721
+ return cells.length > 0 ? cells[0] : null;
5722
+ }
5723
+
5724
+ /** Resolve the first cell matching a predicate (without moving focus). */
5725
+ function getMatching(predicate) {
5726
+ return first(findRow('next', null, row => getRowCells(row).find(predicate) ?? null));
5727
+ }
5728
+ const selectors = {
5729
+ get activeItem() {
5730
+ return getActive();
5731
+ },
5732
+ get hasNavigableItems() {
5733
+ return first(findRow('next')) !== null;
5734
+ },
5735
+ getFirst: () => getBoundaryCell('first'),
5736
+ getLast: () => getBoundaryCell('last'),
5737
+ getMatching
5738
+ };
5739
+ const helpers = {
5740
+ findFirstVisibleRow: () => findVisibleRow('first'),
5741
+ findLastVisibleRow: () => findVisibleRow('last'),
5742
+ findAllVisibleRows,
5743
+ getRowCells,
5744
+ findParentRow,
5745
+ findAdjacentVisibleRow
5746
+ };
5747
+ return {
5748
+ selectors,
5749
+ helpers
5750
+ };
5751
+ }
5752
+
5753
+ /**
5754
+ * Create a focus navigation controller for a 2D grid.
5755
+ *
5756
+ * The controller is composed of two layers:
5757
+ * - a pure, side-effect-free **selection** layer ({@link createGridSelectors}) that only
5758
+ * resolves/reports rows and cells by querying the DOM, and
5759
+ * - a **mover** layer (this function) that commits focus by updating the active-item state
5760
+ * (which fires {@link FocusNavigationCallbacks}) on top of the selectors.
5761
+ *
5762
+ * Supports Up/Down between rows (with column memory) and Left/Right between cells
5763
+ * (with wrapping across rows).
5764
+ *
5765
+ * @param options Grid navigation options (container, rowSelector, cellSelector, isRowVisible, wrap).
5766
+ * @param callbacks Callbacks for focus state changes.
5767
+ * @param signal AbortSignal for cleanup.
5768
+ * @returns FocusNavigationController instance.
5769
+ */
5770
+ function createGridFocusNavigation(options, callbacks, signal) {
5771
+ const {
5772
+ isRowVisible,
5773
+ wrap = false
5774
+ } = options;
5775
+ const state = createActiveItemState(callbacks, signal);
5776
+
5777
+ /** Whether a row is visible (passes the optional `isRowVisible` filter). */
5778
+ const isVisible = row => !isRowVisible || isRowVisible(row);
5779
+ const {
5780
+ selectors,
5781
+ helpers
5782
+ } = createGridSelectors(options, () => state.active, isVisible);
5783
+ const {
5784
+ findFirstVisibleRow,
5785
+ findLastVisibleRow,
5786
+ findAllVisibleRows,
5787
+ getRowCells,
5788
+ findParentRow,
5789
+ findAdjacentVisibleRow
5790
+ } = helpers;
5791
+
5792
+ /** Deferred navigation intent (replayed once cells are committed to the DOM). */
5793
+ const pending = createPendingNavigation(signal);
5794
+ /** Remembered column index for Up/Down navigation (column memory). */
5795
+ let rememberedCol = 0;
5796
+
5797
+ /** Activate the cell at the given column in a row element. */
5667
5798
  function focusCellInRow(row, col) {
5668
5799
  const cells = getRowCells(row);
5669
5800
  if (cells.length === 0) return false;
@@ -5671,14 +5802,18 @@ function createGridFocusNavigation(options, callbacks, signal) {
5671
5802
  state.setActive(cells[clampedCol]);
5672
5803
  return true;
5673
5804
  }
5805
+
5806
+ /** Got to first cell in first row */
5674
5807
  function goToFirst() {
5675
- const row = findVisibleRows('first');
5808
+ const row = findFirstVisibleRow();
5676
5809
  if (!row) return false;
5677
5810
  rememberedCol = 0;
5678
5811
  return focusCellInRow(row, 0);
5679
5812
  }
5813
+
5814
+ /** Got to first cell in last row */
5680
5815
  function goToLast() {
5681
- const row = findVisibleRows('last');
5816
+ const row = findLastVisibleRow();
5682
5817
  if (!row) return false;
5683
5818
  rememberedCol = 0;
5684
5819
  return focusCellInRow(row, 0);
@@ -5703,7 +5838,7 @@ function createGridFocusNavigation(options, callbacks, signal) {
5703
5838
  // Wrap to the adjacent row (or opposite boundary row), activating the first or last cell.
5704
5839
  const rowDirection = step > 0 ? 'next' : 'prev';
5705
5840
  const adjacentRow = findAdjacentVisibleRow(currentRow, rowDirection);
5706
- const targetRow = adjacentRow ?? (step > 0 ? findVisibleRows('first') : findVisibleRows('last'));
5841
+ const targetRow = adjacentRow ?? (step > 0 ? findFirstVisibleRow() : findLastVisibleRow());
5707
5842
  if (!targetRow) return false;
5708
5843
  const targetCells = getRowCells(targetRow);
5709
5844
  if (targetCells.length === 0) return false;
@@ -5725,27 +5860,17 @@ function createGridFocusNavigation(options, callbacks, signal) {
5725
5860
  if (adjacentRow) return focusCellInRow(adjacentRow, rememberedCol);
5726
5861
  if (wrap) {
5727
5862
  // Wrap to the opposite boundary row.
5728
- const wrapRow = direction === 'next' ? findVisibleRows('first') : findVisibleRows('last');
5863
+ const wrapRow = direction === 'next' ? findFirstVisibleRow() : findLastVisibleRow();
5729
5864
  if (wrapRow) return focusCellInRow(wrapRow, rememberedCol);
5730
5865
  }
5731
5866
  return false;
5732
5867
  }
5733
5868
  return {
5734
5869
  type: 'grid',
5735
- get activeItem() {
5736
- return state.active;
5737
- },
5738
- get hasActiveItem() {
5739
- return state.active !== null;
5740
- },
5741
- get hasNavigableItems() {
5742
- return findVisibleRows('first') !== null;
5743
- },
5744
- goToFirst,
5745
- goToLast,
5870
+ selectors,
5746
5871
  goToOffset(offset) {
5747
5872
  if (offset === 0) return state.active !== null;
5748
- const visibleRows = findVisibleRows('all');
5873
+ const visibleRows = findAllVisibleRows();
5749
5874
  if (visibleRows.length === 0) return false;
5750
5875
  if (!state.active) {
5751
5876
  // No active item: jump to first or last row, then apply remaining offset.
@@ -5764,25 +5889,6 @@ function createGridFocusNavigation(options, callbacks, signal) {
5764
5889
  if (targetIdx === rowIdx) return false;
5765
5890
  return focusCellInRow(visibleRows[targetIdx], rememberedCol);
5766
5891
  },
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();
5783
- }
5784
- return false;
5785
- },
5786
5892
  goToItem(item) {
5787
5893
  // Use closest() to find the parent row, then find col within that row only.
5788
5894
  const row = findParentRow(item);
@@ -5796,6 +5902,20 @@ function createGridFocusNavigation(options, callbacks, signal) {
5796
5902
  },
5797
5903
  clear() {
5798
5904
  state.clear();
5905
+ pending.clear();
5906
+ },
5907
+ goTo(resolve) {
5908
+ const target = resolve(selectors);
5909
+ if (target && this.goToItem(target)) {
5910
+ pending.clear();
5911
+ return true;
5912
+ }
5913
+ // Target not resolvable yet (e.g. cells not committed to the DOM) — defer
5914
+ pending.defer(() => this.goTo(resolve));
5915
+ return false;
5916
+ },
5917
+ flushPendingNavigation() {
5918
+ pending.flush();
5799
5919
  },
5800
5920
  goUp() {
5801
5921
  return goVertical('prev');
@@ -5931,7 +6051,7 @@ function setupRovingTabIndex(options, signal) {
5931
6051
  }
5932
6052
  }
5933
6053
  if (!hasTabStop) {
5934
- const fallback = container.querySelector(nav.enabledItemSelector);
6054
+ const fallback = container.querySelector(nav.selectors.enabledItemSelector);
5935
6055
  if (fallback) setTabIndex(fallback, '0');
5936
6056
  }
5937
6057
  }
@@ -5941,7 +6061,7 @@ function setupRovingTabIndex(options, signal) {
5941
6061
  const items = Array.from(container.querySelectorAll(itemSelector));
5942
6062
  const {
5943
6063
  activeItem
5944
- } = nav;
6064
+ } = nav.selectors;
5945
6065
 
5946
6066
  // Prefer either the current active item (from DOM) or the current selected item
5947
6067
  let preferredTabStopIndex = activeItem && items.indexOf(activeItem);
@@ -5958,7 +6078,7 @@ function setupRovingTabIndex(options, signal) {
5958
6078
  /** Ensure a tab stop exists; find a fallback near `anchor` and optionally move focus to it. */
5959
6079
  function ensureTabStop(shouldFocus, anchor) {
5960
6080
  if (container.querySelector(itemActiveSelector)) return;
5961
- const fallback = (anchor && nav.findNearestEnabled(anchor)) ?? container.querySelector(nav.enabledItemSelector);
6081
+ const fallback = (anchor && nav.selectors.findNearestEnabled(anchor)) ?? container.querySelector(nav.selectors.enabledItemSelector);
5962
6082
  if (!fallback) return;
5963
6083
  if (shouldFocus) {
5964
6084
  nav.goToItem(fallback);
@@ -6031,7 +6151,7 @@ function setupRovingTabIndex(options, signal) {
6031
6151
 
6032
6152
  // Handle disabled
6033
6153
  if (disabledTargets.length > 0) {
6034
- const currentActive = nav.activeItem;
6154
+ const currentActive = nav.selectors.activeItem;
6035
6155
  for (const target of disabledTargets) {
6036
6156
  setTabIndex(target, '-1');
6037
6157
  }
@@ -6063,7 +6183,7 @@ function setupRovingTabIndex(options, signal) {
6063
6183
 
6064
6184
  container.addEventListener('keydown', evt => {
6065
6185
  // Adopt focus target if nothing is active yet.
6066
- if (!nav.activeItem) {
6186
+ if (!nav.selectors.activeItem) {
6067
6187
  const target = evt.target;
6068
6188
  if (target.matches(itemSelector) && container.contains(target)) {
6069
6189
  nav.goToItem(target);
@@ -6084,10 +6204,10 @@ function setupRovingTabIndex(options, signal) {
6084
6204
  handled = nav.goUp();
6085
6205
  break;
6086
6206
  case 'Home':
6087
- handled = nav.goToFirst();
6207
+ handled = nav.goTo(s => s.getFirst());
6088
6208
  break;
6089
6209
  case 'End':
6090
- handled = nav.goToLast();
6210
+ handled = nav.goTo(s => s.getLast());
6091
6211
  break;
6092
6212
  }
6093
6213
  if (handled) {
@@ -6248,12 +6368,24 @@ SelectionChipGroup.className = CLASSNAME$1j;
6248
6368
  /**
6249
6369
  * Get the value for a combobox option element.
6250
6370
  * Uses `data-value` when set; falls back to the element's trimmed `textContent`.
6371
+ *
6372
+ * This is the *selection* value , which may differ from the visible label
6251
6373
  */
6252
6374
  function getOptionValue(option) {
6253
6375
  if (option.dataset.value !== undefined) return option.dataset.value;
6254
6376
  return option.textContent?.trim() ?? '';
6255
6377
  }
6256
6378
 
6379
+ /**
6380
+ * Get the visible label for a combobox option element (its trimmed `textContent`).
6381
+ *
6382
+ * Used for typeahead matching: the user types the characters they see, which is the
6383
+ * option's label — not its `data-value` (which can be an unrelated id).
6384
+ */
6385
+ function getOptionLabel(option) {
6386
+ return option.textContent?.trim() ?? '';
6387
+ }
6388
+
6257
6389
  /** Returns true when an option carries aria-disabled="true". */
6258
6390
  function isOptionDisabled(option) {
6259
6391
  return option.getAttribute('aria-disabled') === 'true';
@@ -6265,20 +6397,8 @@ function isActionCell(cell) {
6265
6397
  if (!row) return false;
6266
6398
  return row.querySelector('[role="gridcell"]') !== cell;
6267
6399
  }
6268
-
6269
- /** Predicate matching an option element that carries `aria-selected="true"`. */
6270
6400
  const isSelected = el => el.getAttribute('aria-selected') === 'true';
6271
6401
 
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
6402
  /**
6283
6403
  * Compute the current state of a section and notify when it changed.
6284
6404
  *
@@ -6591,12 +6711,12 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6591
6711
  const nav = handle.focusNav;
6592
6712
  switch (event.key) {
6593
6713
  case 'Enter':
6594
- if (handle.isOpen && nav?.hasActiveItem && nav.activeItem) {
6714
+ if (handle.isOpen && nav?.selectors.activeItem) {
6595
6715
  // Capture activeItem before click — the click handler may close
6596
6716
  // the popover and clear the focus navigation state.
6597
6717
  const {
6598
6718
  activeItem
6599
- } = nav;
6719
+ } = nav.selectors;
6600
6720
  // "Click" on active option
6601
6721
  if (!isOptionDisabled(activeItem)) {
6602
6722
  activeItem.click();
@@ -6615,39 +6735,35 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6615
6735
  // let Enter pass through so it can submit a surrounding form
6616
6736
  break;
6617
6737
  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);
6738
+ if (!handle.isOpen) {
6739
+ handle.setIsOpen(true);
6740
+ if (!altKey) nav?.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6741
+ } else if (nav?.selectors.hasNavigableItems && !altKey) {
6742
+ if (nav.selectors.activeItem) {
6743
+ if (nav.type === 'grid') {
6744
+ nav.goDown();
6745
+ } else if (!nav.goToOffset(1) && wrapNavigation) {
6746
+ nav.goTo(s => s.getFirst());
6628
6747
  }
6629
6748
  } else {
6630
- // Open the listbox and focus selected option, fall back to first.
6631
- handle.setIsOpen(true);
6632
- if (!altKey) goToSelectedOrFirst(nav);
6749
+ nav.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6633
6750
  }
6634
6751
  }
6635
6752
  flag = true;
6636
6753
  break;
6637
6754
  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) {
6755
+ if (!handle.isOpen && !altKey) {
6756
+ handle.setIsOpen(true);
6757
+ nav?.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6758
+ } else if (handle.isOpen && nav?.selectors.hasNavigableItems) {
6759
+ if (nav.selectors.activeItem) {
6644
6760
  if (nav.type === 'grid') {
6645
6761
  nav.goUp();
6646
6762
  } else if (!nav.goToOffset(-1) && wrapNavigation) {
6647
- nav.goToLast();
6763
+ nav.goTo(s => s.getLast());
6648
6764
  }
6649
- } else if (handle.isOpen && !nav.hasActiveItem && !altKey) {
6650
- goToSelectedOrLast(nav);
6765
+ } else if (!altKey) {
6766
+ nav.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6651
6767
  }
6652
6768
  }
6653
6769
  flag = true;
@@ -6662,13 +6778,13 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6662
6778
  flag = true;
6663
6779
  break;
6664
6780
  case 'PageUp':
6665
- if (handle.isOpen && nav?.hasActiveItem) {
6781
+ if (handle.isOpen && nav?.selectors.activeItem) {
6666
6782
  nav.goToOffset(-10);
6667
6783
  }
6668
6784
  flag = true;
6669
6785
  break;
6670
6786
  case 'PageDown':
6671
- if (handle.isOpen && nav?.hasActiveItem) {
6787
+ if (handle.isOpen && nav?.selectors.activeItem) {
6672
6788
  nav.goToOffset(10);
6673
6789
  }
6674
6790
  flag = true;
@@ -6754,25 +6870,16 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6754
6870
  // Update aria-expanded on trigger
6755
6871
  trigger?.setAttribute('aria-expanded', String(isOpen));
6756
6872
  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
6873
  },
6771
6874
  select(option) {
6772
6875
  callbacks.onSelect?.({
6773
6876
  value: option ? getOptionValue(option) : ''
6774
6877
  });
6775
6878
  },
6879
+ flushPendingNavigation() {
6880
+ // Do navigations actions we could not do because the combobox items were not mounted yet
6881
+ focusNav?.flushPendingNavigation();
6882
+ },
6776
6883
  registerOption(element, callback) {
6777
6884
  const filterLower = filterValue.toLowerCase();
6778
6885
  const text = getOptionValue(element).toLowerCase();
@@ -6945,18 +7052,9 @@ function createTypeahead(getWalker, getItemValue, signal) {
6945
7052
  }
6946
7053
  signal.addEventListener('abort', reset);
6947
7054
 
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);
7055
+ // Match the current accumulated search string against the live DOM.
7056
+ function match(currentItem) {
7057
+ if (!searchString) return null;
6960
7058
  const walker = getWalker();
6961
7059
  if (!walker) return null;
6962
7060
 
@@ -7017,8 +7115,29 @@ function createTypeahead(getWalker, getItemValue, signal) {
7017
7115
  }
7018
7116
  }
7019
7117
  }
7118
+
7119
+ // Handle typeahead keys
7120
+ function handle(key, currentItem) {
7121
+ // Clear any pending reset timeout.
7122
+ if (searchTimeout !== undefined) {
7123
+ clearTimeout(searchTimeout);
7124
+ }
7125
+
7126
+ // Accumulate the character.
7127
+ searchString += key.toLowerCase();
7128
+
7129
+ // Schedule clearing the search string after inactivity.
7130
+ searchTimeout = setTimeout(reset, SEARCH_TIMEOUT);
7131
+ return match(currentItem);
7132
+ }
7133
+
7134
+ // Re-run the match for the current buffer without mutating it.
7135
+ function rematch(currentItem) {
7136
+ return match(currentItem);
7137
+ }
7020
7138
  return {
7021
7139
  handle,
7140
+ rematch,
7022
7141
  reset
7023
7142
  };
7024
7143
  }
@@ -7055,7 +7174,7 @@ function setupComboboxButton(button, callbacks) {
7055
7174
  // In list mode, walk option elements.
7056
7175
  const selector = combobox.focusNav?.type === 'grid' ? '[role="gridcell"]' : '[role="option"]';
7057
7176
  return createSelectorTreeWalker(combobox.listbox, selector);
7058
- }, getOptionValue, signal);
7177
+ }, getOptionLabel, signal);
7059
7178
 
7060
7179
  // Click toggles the listbox.
7061
7180
  button.addEventListener('click', () => combobox.setIsOpen(!combobox.isOpen), {
@@ -7066,47 +7185,49 @@ function setupComboboxButton(button, callbacks) {
7066
7185
  switch (event.key) {
7067
7186
  case 'Tab':
7068
7187
  // Selects the focused option
7069
- if (combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7070
- combobox.select(nav.activeItem);
7188
+ if (combobox.isOpen && nav?.selectors.activeItem) {
7189
+ combobox.select(nav.selectors.activeItem);
7071
7190
  }
7072
7191
  // Return false to continue normal 'Tab' behavior (focus next).
7073
7192
  return false;
7074
7193
  case ' ':
7075
7194
  // Space acts like Enter in button mode.
7076
- if (combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7195
+ if (combobox.isOpen && nav?.selectors.activeItem) {
7077
7196
  // Click the active item — delegated handler handles select + close.
7078
- nav.activeItem.click();
7197
+ nav.selectors.activeItem.click();
7079
7198
  } else {
7080
7199
  combobox.setIsOpen(true);
7081
7200
  }
7082
7201
  return true;
7083
7202
  case 'ArrowUp':
7084
7203
  // Alt+ArrowUp: select the focused option and close.
7085
- if (event.altKey && combobox.isOpen && nav?.hasActiveItem && nav.activeItem) {
7086
- combobox.select(nav.activeItem);
7204
+ if (event.altKey && combobox.isOpen && nav?.selectors.activeItem) {
7205
+ combobox.select(nav.selectors.activeItem);
7087
7206
  combobox.setIsOpen(false);
7088
7207
  return true;
7089
7208
  }
7090
7209
  // All other ArrowUp cases handled by base handler.
7091
7210
  return false;
7092
7211
  case 'Home':
7212
+ // `goTo` focuses the first option immediately when open, or defers
7213
+ // until the options commit when opening from closed.
7093
7214
  combobox.setIsOpen(true);
7094
- nav?.goToFirst();
7215
+ nav?.goTo(n => n.getFirst());
7095
7216
  return true;
7096
7217
  case 'End':
7097
7218
  combobox.setIsOpen(true);
7098
- nav?.goToLast();
7219
+ nav?.goTo(n => n.getLast());
7099
7220
  return true;
7100
7221
  case 'ArrowLeft':
7101
7222
  // Grid mode: navigate to previous cell.
7102
- if (nav?.type === 'grid' && combobox.isOpen && nav.hasActiveItem) {
7223
+ if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
7103
7224
  nav.goLeft();
7104
7225
  return true;
7105
7226
  }
7106
7227
  return false;
7107
7228
  case 'ArrowRight':
7108
7229
  // Grid mode: navigate to next cell.
7109
- if (nav?.type === 'grid' && combobox.isOpen && nav.hasActiveItem) {
7230
+ if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
7110
7231
  nav.goRight();
7111
7232
  return true;
7112
7233
  }
@@ -7121,10 +7242,8 @@ function setupComboboxButton(button, callbacks) {
7121
7242
  // Printable characters → typeahead.
7122
7243
  if (isPrintableKey(event)) {
7123
7244
  combobox.setIsOpen(true);
7124
- const match = typeahead.handle(event.key, nav?.activeItem ?? null);
7125
- if (match && nav) {
7126
- nav.goToItem(match);
7127
- }
7245
+ typeahead.handle(event.key, nav?.selectors.activeItem ?? null);
7246
+ nav?.goTo(n => typeahead.rematch(n.activeItem));
7128
7247
  return true;
7129
7248
  }
7130
7249
  return false;
@@ -7448,7 +7567,7 @@ function setupComboboxInput(input, options) {
7448
7567
  case 'ArrowLeft':
7449
7568
  case 'ArrowRight':
7450
7569
  // Grid mode: navigate cells when active item exists.
7451
- if (nav?.type === 'grid' && nav.hasActiveItem) {
7570
+ if (nav?.type === 'grid' && nav.selectors.activeItem) {
7452
7571
  if (event.key === 'ArrowLeft') nav.goLeft();else nav.goRight();
7453
7572
  return true;
7454
7573
  }
@@ -8307,25 +8426,30 @@ const ComboboxList = forwardRef((props, ref) => {
8307
8426
  const listContextValue = useMemo(() => ({
8308
8427
  type
8309
8428
  }), [type]);
8429
+ const [isOpen] = useComboboxOpen();
8430
+ const options = useComboboxEvent('optionsChange', undefined);
8431
+ const visibleCount = options?.optionsLength ?? 0;
8310
8432
 
8311
- // Register the list as the listbox when the handle becomes available
8433
+ // Register list as listbox when handle is available.
8312
8434
  useEffect(() => {
8313
8435
  const list = internalRef.current;
8314
8436
  if (!list) return undefined;
8315
8437
  return handle?.registerListbox(list);
8316
8438
  }, [handle]);
8317
8439
 
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.
8440
+ // Track loading state for aria-busy
8322
8441
  const [isLoading, setIsLoading] = useState(false);
8323
8442
  useEffect(() => {
8324
8443
  if (!handle) return undefined;
8325
- // Read current state synchronously (catches registrations that happened before subscription)
8444
+ // Read current state synchronously (catches registrations before subscription).
8326
8445
  setIsLoading(handle.isLoading);
8327
8446
  return handle.subscribe('loadingChange', setIsLoading);
8328
8447
  }, [handle]);
8448
+
8449
+ // Flush pending keyboard navigation after options commit on open.
8450
+ useLayoutEffect(() => {
8451
+ if (isOpen) handle?.flushPendingNavigation();
8452
+ }, [isOpen, visibleCount, handle]);
8329
8453
  return /*#__PURE__*/jsx(ComboboxListContext.Provider, {
8330
8454
  value: listContextValue,
8331
8455
  children: ComboboxList$1({
@@ -8336,7 +8460,7 @@ const ComboboxList = forwardRef((props, ref) => {
8336
8460
  ref: mergedRef,
8337
8461
  id: listboxId,
8338
8462
  type,
8339
- children
8463
+ children: isOpen ? children : null
8340
8464
  })
8341
8465
  });
8342
8466
  });
@@ -9163,7 +9287,6 @@ const Popover$1 = (props, {
9163
9287
  [`position-${position}`]: Boolean(position),
9164
9288
  'is-hidden': Boolean(isHidden)
9165
9289
  })),
9166
- hidden: isHidden || undefined,
9167
9290
  style: isHidden ? undefined : popoverStyle,
9168
9291
  "data-popper-placement": position,
9169
9292
  children: [unmountSentinel, /*#__PURE__*/jsxs(ClickAwayProvider, {