@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.d.ts +5 -5
- package/index.js +399 -276
- package/index.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
5363
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
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
|
-
|
|
5490
|
-
|
|
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' ?
|
|
5580
|
+
return direction === 'vertical' ? goToOffset(-1) : false;
|
|
5510
5581
|
},
|
|
5511
5582
|
goDown() {
|
|
5512
|
-
return direction === 'vertical' ?
|
|
5583
|
+
return direction === 'vertical' ? goToOffset(1) : false;
|
|
5513
5584
|
},
|
|
5514
5585
|
goLeft() {
|
|
5515
|
-
return direction === 'horizontal' ?
|
|
5586
|
+
return direction === 'horizontal' ? goToOffset(-1) : false;
|
|
5516
5587
|
},
|
|
5517
5588
|
goRight() {
|
|
5518
|
-
return direction === 'horizontal' ?
|
|
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 =
|
|
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
|
|
5656
|
+
* Create the pure selection layer for a 2D grid.
|
|
5570
5657
|
*
|
|
5571
|
-
*
|
|
5572
|
-
*
|
|
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
|
|
5575
|
-
* @param
|
|
5576
|
-
* @param
|
|
5577
|
-
* @returns
|
|
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
|
|
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
|
-
*
|
|
5608
|
-
*
|
|
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
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
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
|
-
|
|
5629
|
-
|
|
5689
|
+
const result = dive ? dive(node) : node;
|
|
5690
|
+
if (result) yield result;
|
|
5630
5691
|
}
|
|
5631
|
-
node =
|
|
5692
|
+
node = advance();
|
|
5632
5693
|
}
|
|
5633
|
-
return found;
|
|
5634
5694
|
}
|
|
5635
5695
|
|
|
5636
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
5665
|
-
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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' ?
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
6207
|
+
handled = nav.goTo(s => s.getFirst());
|
|
6088
6208
|
break;
|
|
6089
6209
|
case 'End':
|
|
6090
|
-
handled = nav.
|
|
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?.
|
|
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 (
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
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
|
-
|
|
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 (
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
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.
|
|
6763
|
+
nav.goTo(s => s.getLast());
|
|
6648
6764
|
}
|
|
6649
|
-
} else if (
|
|
6650
|
-
|
|
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?.
|
|
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?.
|
|
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
|
-
//
|
|
6949
|
-
function
|
|
6950
|
-
|
|
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
|
-
},
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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?.
|
|
7215
|
+
nav?.goTo(n => n.getFirst());
|
|
7095
7216
|
return true;
|
|
7096
7217
|
case 'End':
|
|
7097
7218
|
combobox.setIsOpen(true);
|
|
7098
|
-
nav?.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
7125
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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, {
|