@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.
- package/index.js +193 -315
- package/index.js.map +1 -1
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -5347,37 +5347,85 @@ function createPendingNavigation(signal) {
|
|
|
5347
5347
|
}
|
|
5348
5348
|
|
|
5349
5349
|
/**
|
|
5350
|
-
* Create
|
|
5350
|
+
* Create a focus navigation controller for a 1D list.
|
|
5351
5351
|
*
|
|
5352
|
-
*
|
|
5353
|
-
*
|
|
5354
|
-
*
|
|
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,
|
|
5357
|
-
* @
|
|
5358
|
-
*
|
|
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
|
|
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
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5435
|
-
|
|
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
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
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
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
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
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
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
|
-
|
|
5538
|
-
|
|
5539
|
-
return
|
|
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
|
-
/**
|
|
5543
|
-
function
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5659
|
-
*
|
|
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
|
|
5664
|
-
* @param
|
|
5665
|
-
* @returns
|
|
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
|
|
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
|
-
|
|
5675
|
-
|
|
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
|
-
|
|
5680
|
-
|
|
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
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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.
|
|
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.
|
|
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
|
-
|
|
6744
|
-
|
|
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
|
-
|
|
6761
|
-
|
|
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
|
-
|
|
8328
|
+
useEffect(() => {
|
|
8451
8329
|
if (isOpen) handle?.flushPendingNavigation();
|
|
8452
8330
|
}, [isOpen, visibleCount, handle]);
|
|
8453
8331
|
return /*#__PURE__*/jsx(ComboboxListContext.Provider, {
|