@lumx/react 4.16.0-alpha.6 → 4.16.0-alpha.7

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 +193 -315
  2. package/index.js.map +1 -1
  3. package/package.json +3 -3
package/index.js CHANGED
@@ -5347,37 +5347,85 @@ function createPendingNavigation(signal) {
5347
5347
  }
5348
5348
 
5349
5349
  /**
5350
- * Create the pure selection layer for a 1D list.
5350
+ * Create a focus navigation controller for a 1D list.
5351
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`).
5352
+ * This controller is **stateless** it does not maintain an internal reference to
5353
+ * the active item. Instead it reads the active item from the DOM each time via the
5354
+ * `getActiveItem` callback provided in the options. This avoids any desync between
5355
+ * the controller's internal state and the actual DOM.
5355
5356
  *
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.
5357
+ * @param options List navigation options (container, itemSelector, direction, wrap, getActiveItem).
5358
+ * @param callbacks Callbacks for focus state changes.
5359
+ * @param signal AbortSignal for cleanup.
5360
+ * @returns ListFocusNavigationController instance.
5359
5361
  */
5360
- function createListSelectors(options) {
5362
+ function createListFocusNavigation(options, callbacks, signal) {
5361
5363
  const {
5362
5364
  container,
5363
5365
  itemSelector,
5366
+ direction = 'vertical',
5367
+ wrap = false,
5364
5368
  itemDisabledSelector,
5365
5369
  getActiveItem = () => null
5366
5370
  } = options;
5367
5371
 
5368
5372
  /** Combined CSS selector matching enabled (non-disabled) items. */
5369
5373
  const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
5374
+
5375
+ /** Create a TreeWalker over items in the container. */
5370
5376
  function createItemWalker(enabledOnly = true) {
5371
5377
  const selector = enabledOnly ? enabledItemSelector : itemSelector;
5372
5378
  return createSelectorTreeWalker(container, selector);
5373
5379
  }
5380
+
5381
+ /** Find the first enabled item in the container. */
5374
5382
  function findFirstEnabled() {
5375
5383
  return container.querySelector(enabledItemSelector);
5376
5384
  }
5385
+
5386
+ /** Find the last enabled item in the container. */
5377
5387
  function findLastEnabled() {
5378
5388
  const items = container.querySelectorAll(enabledItemSelector);
5379
5389
  return items.length > 0 ? items[items.length - 1] : null;
5380
5390
  }
5391
+
5392
+ // Deferred navigation intent (replayed once items are committed to the DOM)
5393
+ const pending = createPendingNavigation(signal);
5394
+
5395
+ /** Find item at offset (lazily walk nodes) */
5396
+ function findAtOffset(offset) {
5397
+ const forward = offset > 0;
5398
+ const stepsNeeded = Math.abs(offset);
5399
+ const active = getActiveItem();
5400
+ const walker = createItemWalker();
5401
+ const step = forward ? () => walker.nextNode() : () => walker.previousNode();
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++) {
5414
+ const next = step();
5415
+ if (next) {
5416
+ target = next;
5417
+ } else if (active && wrap) {
5418
+ // Hit boundary with an active item — wrap around to the opposite end.
5419
+ const wrapped = forward ? findFirstEnabled() : findLastEnabled();
5420
+ if (!wrapped || wrapped === active) break;
5421
+ target = wrapped;
5422
+ walker.currentNode = wrapped;
5423
+ } else {
5424
+ break;
5425
+ }
5426
+ }
5427
+ return target;
5428
+ }
5381
5429
  function findMatching(predicate) {
5382
5430
  const walker = createItemWalker(false);
5383
5431
  let node = walker.nextNode();
@@ -5387,24 +5435,19 @@ function createListSelectors(options) {
5387
5435
  }
5388
5436
  return null;
5389
5437
  }
5390
- function findNearestEnabled(anchor) {
5391
- if (!container.contains(anchor)) return findFirstEnabled();
5392
5438
 
5393
- // If the anchor itself is an enabled item, return it directly.
5394
- if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
5395
- return anchor;
5439
+ /** Clear the active item and discard any pending navigation intent. */
5440
+ function clear() {
5441
+ const current = getActiveItem();
5442
+ if (current) {
5443
+ callbacks.onDeactivate(current);
5396
5444
  }
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();
5445
+ pending.clear();
5446
+ callbacks.onClear?.();
5407
5447
  }
5448
+
5449
+ // Cleanup on abort.
5450
+ signal.addEventListener('abort', clear);
5408
5451
  const selectors = {
5409
5452
  enabledItemSelector,
5410
5453
  get activeItem() {
@@ -5416,163 +5459,60 @@ function createListSelectors(options) {
5416
5459
  getFirst: findFirstEnabled,
5417
5460
  getLast: findLastEnabled,
5418
5461
  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
- }
5462
+ findNearestEnabled(anchor) {
5463
+ if (!container.contains(anchor)) return findFirstEnabled();
5432
5464
 
5433
- /**
5434
- * Transition the active item: deactivate the current one (if any) and activate the new one.
5435
- * Reads the current active item via `getActiveItem` so there is no internal state to desync.
5436
- */
5437
- function transition(getActiveItem, callbacks, newItem) {
5438
- const current = getActiveItem();
5439
- if (current === newItem) return;
5440
- if (current) callbacks.onDeactivate(current);
5441
- callbacks.onActivate(newItem);
5442
- }
5443
-
5444
- /**
5445
- * Create a focus navigation controller for a 1D list.
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
- *
5453
- * This controller is **stateless** — it does not maintain an internal reference to
5454
- * the active item. Instead it reads the active item from the DOM each time via the
5455
- * `getActiveItem` callback provided in the options. This avoids any desync between
5456
- * the controller's internal state and the actual DOM.
5457
- *
5458
- * @param options List navigation options (container, itemSelector, direction, wrap, getActiveItem).
5459
- * @param callbacks Callbacks for focus state changes.
5460
- * @param signal AbortSignal for cleanup.
5461
- * @returns ListFocusNavigationController instance.
5462
- */
5463
- function createListFocusNavigation(options, callbacks, signal) {
5464
- const {
5465
- itemSelector,
5466
- container,
5467
- direction = 'vertical',
5468
- wrap = false
5469
- } = options;
5470
- const {
5471
- selectors,
5472
- helpers
5473
- } = createListSelectors(options);
5474
- const {
5475
- getActiveItem,
5476
- createItemWalker,
5477
- findFirstEnabled,
5478
- findLastEnabled
5479
- } = helpers;
5480
-
5481
- // Deferred navigation intent (replayed once items are committed to the DOM)
5482
- const pending = createPendingNavigation(signal);
5483
-
5484
- /** Navigate to the first enabled item and activate it. */
5485
- function goToFirst() {
5486
- const first = findFirstEnabled();
5487
- if (!first) return false;
5488
- transition(getActiveItem, callbacks, first);
5489
- return true;
5490
- }
5491
-
5492
- /** Navigate to the last enabled item and activate it. */
5493
- function goToLast() {
5494
- const last = findLastEnabled();
5495
- if (!last) return false;
5496
- transition(getActiveItem, callbacks, last);
5497
- return true;
5498
- }
5465
+ // If the anchor itself is an enabled item, return it directly.
5466
+ if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
5467
+ return anchor;
5468
+ }
5499
5469
 
5500
- /** Go to item at an offset */
5501
- function goToOffset(offset) {
5502
- const active = getActiveItem();
5503
- if (offset === 0) return active !== null;
5504
- const forward = offset > 0;
5505
- const stepsNeeded = Math.abs(offset);
5470
+ // Walk forward from the anchor for the nearest enabled item.
5471
+ const walker = createItemWalker();
5472
+ walker.currentNode = anchor;
5473
+ const next = walker.nextNode();
5474
+ if (next instanceof HTMLElement) return next;
5506
5475
 
5507
- // No active item — fall back to first/last.
5508
- if (!active) {
5509
- const started = forward ? goToFirst() : goToLast();
5510
- if (!started) return false;
5511
- if (stepsNeeded === 1) return true;
5512
- return goToOffset(forward ? offset - 1 : offset + 1);
5476
+ // No enabled item after anchor walk backward (reuse same walker).
5477
+ walker.currentNode = anchor;
5478
+ return walker.previousNode();
5513
5479
  }
5480
+ };
5514
5481
 
5515
- // Walk from the active item using a TreeWalker.
5516
- const walker = createItemWalker();
5517
- walker.currentNode = active;
5518
- const step = forward ? () => walker.nextNode() : () => walker.previousNode();
5519
- let stepsCompleted = 0;
5520
- let lastFound = null;
5521
- for (let i = 0; i < stepsNeeded; i++) {
5522
- const next = step();
5523
- if (next) {
5524
- lastFound = next;
5525
- stepsCompleted += 1;
5526
- } else if (wrap) {
5527
- // Hit boundary — wrap around to the opposite end.
5528
- const wrapped = forward ? findFirstEnabled() : findLastEnabled();
5529
- if (!wrapped || wrapped === active) break;
5530
- lastFound = wrapped;
5531
- stepsCompleted += 1;
5532
- walker.currentNode = wrapped;
5533
- } else {
5534
- break;
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);
5535
5494
  }
5495
+ pending.clear();
5496
+ return true;
5536
5497
  }
5537
- if (stepsCompleted === 0) return false;
5538
- transition(getActiveItem, callbacks, lastFound);
5539
- return true;
5498
+ // Target not resolvable yet (e.g. items not committed to the DOM) defer
5499
+ pending.defer(() => goTo(resolve));
5500
+ return false;
5540
5501
  }
5541
5502
 
5542
- /** Clear the active item and discard any pending navigation intent. */
5543
- function clear() {
5544
- const current = getActiveItem();
5545
- if (current) {
5546
- callbacks.onDeactivate(current);
5547
- }
5548
- pending.clear();
5549
- callbacks.onClear?.();
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);
5550
5509
  }
5551
-
5552
- // Cleanup on abort.
5553
- signal.addEventListener('abort', clear);
5554
5510
  return {
5555
5511
  type: 'list',
5556
5512
  selectors,
5557
- goToItem(item) {
5558
- if (!item.matches(itemSelector)) return false;
5559
- if (!container.contains(item)) return false;
5560
- transition(getActiveItem, callbacks, item);
5561
- return true;
5562
- },
5563
5513
  goToOffset,
5564
5514
  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;
5571
- }
5572
- // Target not resolvable yet (e.g. items not committed to the DOM) — defer
5573
- pending.defer(() => this.goTo(resolve));
5574
- return false;
5575
- },
5515
+ goTo,
5576
5516
  flushPendingNavigation() {
5577
5517
  pending.flush();
5578
5518
  },
@@ -5609,7 +5549,7 @@ function createListFocusNavigation(options, callbacks, signal) {
5609
5549
  * @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
5610
5550
  */
5611
5551
  function createActiveItemState(callbacks, signal, initialItem) {
5612
- let activeItem = null;
5552
+ let activeItem = initialItem ?? null;
5613
5553
  function clear() {
5614
5554
  if (activeItem) {
5615
5555
  callbacks.onDeactivate(activeItem);
@@ -5653,141 +5593,57 @@ function first(iterable) {
5653
5593
  }
5654
5594
 
5655
5595
  /**
5656
- * Create the pure selection layer for a 2D grid.
5596
+ * Create a focus navigation controller for a 2D grid.
5597
+ *
5598
+ * Resolves rows and cells by querying the DOM and commits focus by updating the
5599
+ * active-item state (which fires {@link FocusNavigationCallbacks}).
5657
5600
  *
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.
5601
+ * Supports Up/Down between rows (with column memory) and Left/Right between cells
5602
+ * (with wrapping across rows).
5661
5603
  *
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.
5604
+ * @param options Grid navigation options (container, rowSelector, cellSelector, wrap).
5605
+ * @param callbacks Callbacks for focus state changes.
5606
+ * @param signal AbortSignal for cleanup.
5607
+ * @returns FocusNavigationController instance.
5667
5608
  */
5668
- function createGridSelectors(options, getActive, isVisible) {
5609
+ function createGridFocusNavigation(options, callbacks, signal) {
5669
5610
  const {
5670
5611
  container,
5671
5612
  rowSelector,
5672
- cellSelector
5613
+ cellSelector,
5614
+ wrap = false
5673
5615
  } = options;
5674
- function isNavigableRow(row) {
5675
- return isVisible(row) && row.querySelector(cellSelector) !== null;
5676
- }
5616
+ const state = createActiveItemState(callbacks, signal);
5617
+ const isNavigableRow = row => row.querySelector(cellSelector) !== null;
5618
+ const getFirstCell = row => row.querySelector(cellSelector);
5619
+ const getRowCells = row => Array.from(row.querySelectorAll(cellSelector));
5677
5620
 
5678
- /**
5679
- * Lazily walk navigable rows (visible, with cells) starting from `startNode` in
5680
- * `direction`, projecting each row through `dive`.
5681
- */
5682
- function* findRow(direction, startNode = null, dive) {
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
5683
5624
  const walker = createSelectorTreeWalker(container, rowSelector);
5684
- if (startNode) walker.currentNode = startNode;
5685
- const advance = direction === 'next' ? () => walker.nextNode() : () => walker.previousNode();
5686
- let node = advance();
5687
- while (node) {
5688
- if (isNavigableRow(node)) {
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)) {
5689
5636
  const result = dive ? dive(node) : node;
5690
5637
  if (result) yield result;
5691
5638
  }
5692
- node = advance();
5693
- }
5694
- }
5695
-
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
- }
5705
- function getRowCells(row) {
5706
- return Array.from(row.querySelectorAll(cellSelector));
5639
+ } while (node);
5707
5640
  }
5641
+ const findFirstVisibleRow = () => first(findRows());
5642
+ const findLastVisibleRow = () => first(findRows('last', 'prev'));
5708
5643
  function findParentRow(cell) {
5709
5644
  const row = cell.closest(rowSelector);
5710
5645
  return row && container.contains(row) ? row : null;
5711
5646
  }
5712
- function findAdjacentVisibleRow(fromRow, direction) {
5713
- return first(findRow(direction, fromRow));
5714
- }
5715
-
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
5647
 
5792
5648
  /** Deferred navigation intent (replayed once cells are committed to the DOM). */
5793
5649
  const pending = createPendingNavigation(signal);
@@ -5803,6 +5659,18 @@ function createGridFocusNavigation(options, callbacks, signal) {
5803
5659
  return true;
5804
5660
  }
5805
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
+
5806
5674
  /** Got to first cell in first row */
5807
5675
  function goToFirst() {
5808
5676
  const row = findFirstVisibleRow();
@@ -5837,7 +5705,7 @@ function createGridFocusNavigation(options, callbacks, signal) {
5837
5705
 
5838
5706
  // Wrap to the adjacent row (or opposite boundary row), activating the first or last cell.
5839
5707
  const rowDirection = step > 0 ? 'next' : 'prev';
5840
- const adjacentRow = findAdjacentVisibleRow(currentRow, rowDirection);
5708
+ const adjacentRow = first(findRows(currentRow, rowDirection));
5841
5709
  const targetRow = adjacentRow ?? (step > 0 ? findFirstVisibleRow() : findLastVisibleRow());
5842
5710
  if (!targetRow) return false;
5843
5711
  const targetCells = getRowCells(targetRow);
@@ -5856,7 +5724,7 @@ function createGridFocusNavigation(options, callbacks, signal) {
5856
5724
  }
5857
5725
  const currentRow = findParentRow(state.active);
5858
5726
  if (!currentRow) return false;
5859
- const adjacentRow = findAdjacentVisibleRow(currentRow, direction);
5727
+ const adjacentRow = first(findRows(currentRow, direction));
5860
5728
  if (adjacentRow) return focusCellInRow(adjacentRow, rememberedCol);
5861
5729
  if (wrap) {
5862
5730
  // Wrap to the opposite boundary row.
@@ -5865,12 +5733,26 @@ function createGridFocusNavigation(options, callbacks, signal) {
5865
5733
  }
5866
5734
  return false;
5867
5735
  }
5736
+ const selectors = {
5737
+ get activeItem() {
5738
+ return state.active;
5739
+ },
5740
+ get hasNavigableItems() {
5741
+ return first(findRows()) !== null;
5742
+ },
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
+ };
5868
5750
  return {
5869
5751
  type: 'grid',
5870
5752
  selectors,
5871
5753
  goToOffset(offset) {
5872
5754
  if (offset === 0) return state.active !== null;
5873
- const visibleRows = findAllVisibleRows();
5755
+ const visibleRows = Array.from(findRows());
5874
5756
  if (visibleRows.length === 0) return false;
5875
5757
  if (!state.active) {
5876
5758
  // No active item: jump to first or last row, then apply remaining offset.
@@ -5889,24 +5771,13 @@ function createGridFocusNavigation(options, callbacks, signal) {
5889
5771
  if (targetIdx === rowIdx) return false;
5890
5772
  return focusCellInRow(visibleRows[targetIdx], rememberedCol);
5891
5773
  },
5892
- goToItem(item) {
5893
- // Use closest() to find the parent row, then find col within that row only.
5894
- const row = findParentRow(item);
5895
- if (!row || !isVisible(row)) return false;
5896
- const cells = getRowCells(row);
5897
- const col = cells.indexOf(item);
5898
- if (col === -1) return false;
5899
- rememberedCol = col;
5900
- state.setActive(item);
5901
- return true;
5902
- },
5903
5774
  clear() {
5904
5775
  state.clear();
5905
5776
  pending.clear();
5906
5777
  },
5907
5778
  goTo(resolve) {
5908
5779
  const target = resolve(selectors);
5909
- if (target && this.goToItem(target)) {
5780
+ if (target && activateCell(target)) {
5910
5781
  pending.clear();
5911
5782
  return true;
5912
5783
  }
@@ -6081,7 +5952,7 @@ function setupRovingTabIndex(options, signal) {
6081
5952
  const fallback = (anchor && nav.selectors.findNearestEnabled(anchor)) ?? container.querySelector(nav.selectors.enabledItemSelector);
6082
5953
  if (!fallback) return;
6083
5954
  if (shouldFocus) {
6084
- nav.goToItem(fallback);
5955
+ nav.goTo(() => fallback);
6085
5956
  } else {
6086
5957
  setTabIndex(fallback, '0');
6087
5958
  }
@@ -6186,7 +6057,7 @@ function setupRovingTabIndex(options, signal) {
6186
6057
  if (!nav.selectors.activeItem) {
6187
6058
  const target = evt.target;
6188
6059
  if (target.matches(itemSelector) && container.contains(target)) {
6189
- nav.goToItem(target);
6060
+ nav.goTo(() => target);
6190
6061
  }
6191
6062
  }
6192
6063
  let handled = false;
@@ -6450,7 +6321,7 @@ function notifySection(sectionElement, sectionRegistrations, optionRegistrations
6450
6321
  * @param notify Notify subscribers of combobox events.
6451
6322
  * @returns The created focus navigation controller.
6452
6323
  */
6453
- function setupListbox(handle, signal, notify) {
6324
+ function setupListbox(handle, signal, notify, options) {
6454
6325
  const trigger = handle.trigger;
6455
6326
  const listbox = handle.listbox;
6456
6327
  const isGrid = listbox.getAttribute('role') === 'grid';
@@ -6503,6 +6374,7 @@ function setupListbox(handle, signal, notify) {
6503
6374
  focusNav = createListFocusNavigation({
6504
6375
  container: listbox,
6505
6376
  itemSelector,
6377
+ wrap: options?.wrapNavigation,
6506
6378
  getActiveItem: () => {
6507
6379
  const id = trigger.getAttribute('aria-activedescendant');
6508
6380
  return id ? document.getElementById(id) : null;
@@ -6734,35 +6606,37 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6734
6606
  // Otherwise (closed popup, or multi-select with no active item),
6735
6607
  // let Enter pass through so it can submit a surrounding form
6736
6608
  break;
6609
+
6610
+ // Open if closed, else move focus within listbox (wrap if enabled).
6737
6611
  case 'ArrowDown':
6738
6612
  if (!handle.isOpen) {
6739
6613
  handle.setIsOpen(true);
6614
+ // Focus first or selected item on open.
6740
6615
  if (!altKey) nav?.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6741
6616
  } else if (nav?.selectors.hasNavigableItems && !altKey) {
6742
6617
  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());
6747
- }
6618
+ // Go down
6619
+ nav.goDown();
6748
6620
  } else {
6621
+ // Focus first or selected item when no active item.
6749
6622
  nav.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
6750
6623
  }
6751
6624
  }
6752
6625
  flag = true;
6753
6626
  break;
6627
+
6628
+ // Open if closed, else move focus within listbox (wrap if enabled).
6754
6629
  case 'ArrowUp':
6755
6630
  if (!handle.isOpen && !altKey) {
6756
6631
  handle.setIsOpen(true);
6632
+ // Focus last or selected item on open.
6757
6633
  nav?.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6758
6634
  } else if (handle.isOpen && nav?.selectors.hasNavigableItems) {
6759
6635
  if (nav.selectors.activeItem) {
6760
- if (nav.type === 'grid') {
6761
- nav.goUp();
6762
- } else if (!nav.goToOffset(-1) && wrapNavigation) {
6763
- nav.goTo(s => s.getLast());
6764
- }
6636
+ // Go up
6637
+ nav.goUp();
6765
6638
  } else if (!altKey) {
6639
+ // Focus last or selected item when no active item.
6766
6640
  nav.goTo(s => s.getMatching(isSelected) ?? s.getLast());
6767
6641
  }
6768
6642
  }
@@ -6829,7 +6703,9 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6829
6703
  });
6830
6704
  }
6831
6705
  if (listbox && !focusNav) {
6832
- focusNav = setupListbox(handle, abortController.signal, notify);
6706
+ focusNav = setupListbox(handle, abortController.signal, notify, {
6707
+ wrapNavigation
6708
+ });
6833
6709
  }
6834
6710
  }
6835
6711
  handle = {
@@ -6974,7 +6850,9 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6974
6850
  if (trigger && abortController) {
6975
6851
  if (!hadListbox) {
6976
6852
  // First listbox — set up focus nav and listbox listeners
6977
- focusNav = setupListbox(handle, abortController.signal, notify);
6853
+ focusNav = setupListbox(handle, abortController.signal, notify, {
6854
+ wrapNavigation
6855
+ });
6978
6856
  } else {
6979
6857
  // Replacing listbox — full re-attach
6980
6858
  detach();
@@ -8447,7 +8325,7 @@ const ComboboxList = forwardRef((props, ref) => {
8447
8325
  }, [handle]);
8448
8326
 
8449
8327
  // Flush pending keyboard navigation after options commit on open.
8450
- useLayoutEffect(() => {
8328
+ useEffect(() => {
8451
8329
  if (isOpen) handle?.flushPendingNavigation();
8452
8330
  }, [isOpen, visibleCount, handle]);
8453
8331
  return /*#__PURE__*/jsx(ComboboxListContext.Provider, {