@lumx/react 4.16.0-alpha.3 → 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 +23 -22
- package/index.js +442 -314
- package/index.js.map +1 -1
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -1687,7 +1687,7 @@ const IconButton$1 = props => {
|
|
|
1687
1687
|
size = DEFAULT_PROPS$1a.size,
|
|
1688
1688
|
...forwardedProps
|
|
1689
1689
|
} = props;
|
|
1690
|
-
const
|
|
1690
|
+
const defaultChildren = image ? /*#__PURE__*/jsx("img", {
|
|
1691
1691
|
// no need to set alt as an aria-label is already set on the button
|
|
1692
1692
|
alt: "",
|
|
1693
1693
|
src: image
|
|
@@ -1700,12 +1700,7 @@ const IconButton$1 = props => {
|
|
|
1700
1700
|
...forwardedProps,
|
|
1701
1701
|
'aria-label': label,
|
|
1702
1702
|
variant: 'icon',
|
|
1703
|
-
children:
|
|
1704
|
-
children: [iconNode, /*#__PURE__*/jsx("span", {
|
|
1705
|
-
className: visuallyHidden(),
|
|
1706
|
-
children: label
|
|
1707
|
-
})]
|
|
1708
|
-
})
|
|
1703
|
+
children: defaultChildren
|
|
1709
1704
|
});
|
|
1710
1705
|
};
|
|
1711
1706
|
IconButton$1.displayName = COMPONENT_NAME$1r;
|
|
@@ -5330,6 +5325,111 @@ Tooltip.displayName = COMPONENT_NAME$1i;
|
|
|
5330
5325
|
Tooltip.className = CLASSNAME$1h;
|
|
5331
5326
|
Tooltip.defaultProps = DEFAULT_PROPS$12;
|
|
5332
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
|
+
|
|
5333
5433
|
/**
|
|
5334
5434
|
* Transition the active item: deactivate the current one (if any) and activate the new one.
|
|
5335
5435
|
* Reads the current active item via `getActiveItem` so there is no internal state to desync.
|
|
@@ -5344,6 +5444,12 @@ function transition(getActiveItem, callbacks, newItem) {
|
|
|
5344
5444
|
/**
|
|
5345
5445
|
* Create a focus navigation controller for a 1D list.
|
|
5346
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
|
+
*
|
|
5347
5453
|
* This controller is **stateless** — it does not maintain an internal reference to
|
|
5348
5454
|
* the active item. Instead it reads the active item from the DOM each time via the
|
|
5349
5455
|
* `getActiveItem` callback provided in the options. This avoids any desync between
|
|
@@ -5356,36 +5462,24 @@ function transition(getActiveItem, callbacks, newItem) {
|
|
|
5356
5462
|
*/
|
|
5357
5463
|
function createListFocusNavigation(options, callbacks, signal) {
|
|
5358
5464
|
const {
|
|
5359
|
-
container,
|
|
5360
5465
|
itemSelector,
|
|
5466
|
+
container,
|
|
5361
5467
|
direction = 'vertical',
|
|
5362
|
-
wrap = false
|
|
5363
|
-
itemDisabledSelector,
|
|
5364
|
-
getActiveItem = () => null
|
|
5468
|
+
wrap = false
|
|
5365
5469
|
} = options;
|
|
5470
|
+
const {
|
|
5471
|
+
selectors,
|
|
5472
|
+
helpers
|
|
5473
|
+
} = createListSelectors(options);
|
|
5474
|
+
const {
|
|
5475
|
+
getActiveItem,
|
|
5476
|
+
createItemWalker,
|
|
5477
|
+
findFirstEnabled,
|
|
5478
|
+
findLastEnabled
|
|
5479
|
+
} = helpers;
|
|
5366
5480
|
|
|
5367
|
-
|
|
5368
|
-
const
|
|
5369
|
-
|
|
5370
|
-
/**
|
|
5371
|
-
* Create a TreeWalker over items in the container.
|
|
5372
|
-
* @param enabledOnly When true (default), disabled items are skipped.
|
|
5373
|
-
*/
|
|
5374
|
-
function createItemWalker(enabledOnly = true) {
|
|
5375
|
-
const selector = enabledOnly ? enabledItemSelector : itemSelector;
|
|
5376
|
-
return createSelectorTreeWalker(container, selector);
|
|
5377
|
-
}
|
|
5378
|
-
|
|
5379
|
-
/** Find the first enabled item in the container. */
|
|
5380
|
-
function findFirstEnabled() {
|
|
5381
|
-
return container.querySelector(enabledItemSelector);
|
|
5382
|
-
}
|
|
5383
|
-
|
|
5384
|
-
/** Find the last enabled item in the container. */
|
|
5385
|
-
function findLastEnabled() {
|
|
5386
|
-
const items = container.querySelectorAll(enabledItemSelector);
|
|
5387
|
-
return items.length > 0 ? items[items.length - 1] : null;
|
|
5388
|
-
}
|
|
5481
|
+
// Deferred navigation intent (replayed once items are committed to the DOM)
|
|
5482
|
+
const pending = createPendingNavigation(signal);
|
|
5389
5483
|
|
|
5390
5484
|
/** Navigate to the first enabled item and activate it. */
|
|
5391
5485
|
function goToFirst() {
|
|
@@ -5402,7 +5496,9 @@ function createListFocusNavigation(options, callbacks, signal) {
|
|
|
5402
5496
|
transition(getActiveItem, callbacks, last);
|
|
5403
5497
|
return true;
|
|
5404
5498
|
}
|
|
5405
|
-
|
|
5499
|
+
|
|
5500
|
+
/** Go to item at an offset */
|
|
5501
|
+
function goToOffset(offset) {
|
|
5406
5502
|
const active = getActiveItem();
|
|
5407
5503
|
if (offset === 0) return active !== null;
|
|
5408
5504
|
const forward = offset > 0;
|
|
@@ -5413,7 +5509,7 @@ function createListFocusNavigation(options, callbacks, signal) {
|
|
|
5413
5509
|
const started = forward ? goToFirst() : goToLast();
|
|
5414
5510
|
if (!started) return false;
|
|
5415
5511
|
if (stepsNeeded === 1) return true;
|
|
5416
|
-
return
|
|
5512
|
+
return goToOffset(forward ? offset - 1 : offset + 1);
|
|
5417
5513
|
}
|
|
5418
5514
|
|
|
5419
5515
|
// Walk from the active item using a TreeWalker.
|
|
@@ -5442,15 +5538,14 @@ function createListFocusNavigation(options, callbacks, signal) {
|
|
|
5442
5538
|
transition(getActiveItem, callbacks, lastFound);
|
|
5443
5539
|
return true;
|
|
5444
5540
|
}
|
|
5445
|
-
const navigateForward = () => navigateByOffset(1);
|
|
5446
|
-
const navigateBackward = () => navigateByOffset(-1);
|
|
5447
5541
|
|
|
5448
|
-
/** Clear the active item. */
|
|
5542
|
+
/** Clear the active item and discard any pending navigation intent. */
|
|
5449
5543
|
function clear() {
|
|
5450
5544
|
const current = getActiveItem();
|
|
5451
5545
|
if (current) {
|
|
5452
5546
|
callbacks.onDeactivate(current);
|
|
5453
5547
|
}
|
|
5548
|
+
pending.clear();
|
|
5454
5549
|
callbacks.onClear?.();
|
|
5455
5550
|
}
|
|
5456
5551
|
|
|
@@ -5458,69 +5553,40 @@ function createListFocusNavigation(options, callbacks, signal) {
|
|
|
5458
5553
|
signal.addEventListener('abort', clear);
|
|
5459
5554
|
return {
|
|
5460
5555
|
type: 'list',
|
|
5461
|
-
|
|
5462
|
-
get activeItem() {
|
|
5463
|
-
return getActiveItem();
|
|
5464
|
-
},
|
|
5465
|
-
get hasActiveItem() {
|
|
5466
|
-
return getActiveItem() !== null;
|
|
5467
|
-
},
|
|
5468
|
-
get hasNavigableItems() {
|
|
5469
|
-
return container.querySelector(enabledItemSelector) !== null;
|
|
5470
|
-
},
|
|
5471
|
-
goToFirst,
|
|
5472
|
-
goToLast,
|
|
5556
|
+
selectors,
|
|
5473
5557
|
goToItem(item) {
|
|
5474
5558
|
if (!item.matches(itemSelector)) return false;
|
|
5475
5559
|
if (!container.contains(item)) return false;
|
|
5476
5560
|
transition(getActiveItem, callbacks, item);
|
|
5477
5561
|
return true;
|
|
5478
5562
|
},
|
|
5479
|
-
goToOffset
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
transition(getActiveItem, callbacks, node);
|
|
5488
|
-
return true;
|
|
5489
|
-
}
|
|
5490
|
-
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;
|
|
5491
5571
|
}
|
|
5572
|
+
// Target not resolvable yet (e.g. items not committed to the DOM) — defer
|
|
5573
|
+
pending.defer(() => this.goTo(resolve));
|
|
5492
5574
|
return false;
|
|
5493
5575
|
},
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
// If the anchor itself is an enabled item, return it directly.
|
|
5498
|
-
if (anchor instanceof HTMLElement && anchor.matches(enabledItemSelector)) {
|
|
5499
|
-
return anchor;
|
|
5500
|
-
}
|
|
5501
|
-
|
|
5502
|
-
// Walk forward from the anchor for the nearest enabled item.
|
|
5503
|
-
const walker = createItemWalker();
|
|
5504
|
-
walker.currentNode = anchor;
|
|
5505
|
-
const next = walker.nextNode();
|
|
5506
|
-
if (next instanceof HTMLElement) return next;
|
|
5507
|
-
|
|
5508
|
-
// No enabled item after anchor — walk backward (reuse same walker).
|
|
5509
|
-
walker.currentNode = anchor;
|
|
5510
|
-
return walker.previousNode();
|
|
5576
|
+
flushPendingNavigation() {
|
|
5577
|
+
pending.flush();
|
|
5511
5578
|
},
|
|
5512
|
-
clear,
|
|
5513
5579
|
goUp() {
|
|
5514
|
-
return direction === 'vertical' ?
|
|
5580
|
+
return direction === 'vertical' ? goToOffset(-1) : false;
|
|
5515
5581
|
},
|
|
5516
5582
|
goDown() {
|
|
5517
|
-
return direction === 'vertical' ?
|
|
5583
|
+
return direction === 'vertical' ? goToOffset(1) : false;
|
|
5518
5584
|
},
|
|
5519
5585
|
goLeft() {
|
|
5520
|
-
return direction === 'horizontal' ?
|
|
5586
|
+
return direction === 'horizontal' ? goToOffset(-1) : false;
|
|
5521
5587
|
},
|
|
5522
5588
|
goRight() {
|
|
5523
|
-
return direction === 'horizontal' ?
|
|
5589
|
+
return direction === 'horizontal' ? goToOffset(1) : false;
|
|
5524
5590
|
}
|
|
5525
5591
|
};
|
|
5526
5592
|
}
|
|
@@ -5543,7 +5609,7 @@ function createListFocusNavigation(options, callbacks, signal) {
|
|
|
5543
5609
|
* @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
|
|
5544
5610
|
*/
|
|
5545
5611
|
function createActiveItemState(callbacks, signal, initialItem) {
|
|
5546
|
-
let activeItem =
|
|
5612
|
+
let activeItem = null;
|
|
5547
5613
|
function clear() {
|
|
5548
5614
|
if (activeItem) {
|
|
5549
5615
|
callbacks.onDeactivate(activeItem);
|
|
@@ -5570,105 +5636,165 @@ function createActiveItemState(callbacks, signal, initialItem) {
|
|
|
5570
5636
|
};
|
|
5571
5637
|
}
|
|
5572
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
|
+
|
|
5573
5655
|
/**
|
|
5574
|
-
* Create
|
|
5656
|
+
* Create the pure selection layer for a 2D grid.
|
|
5575
5657
|
*
|
|
5576
|
-
*
|
|
5577
|
-
*
|
|
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.
|
|
5578
5661
|
*
|
|
5579
|
-
* @param options Grid navigation options (container, rowSelector, cellSelector
|
|
5580
|
-
* @param
|
|
5581
|
-
* @param
|
|
5582
|
-
* @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.
|
|
5583
5667
|
*/
|
|
5584
|
-
function
|
|
5668
|
+
function createGridSelectors(options, getActive, isVisible) {
|
|
5585
5669
|
const {
|
|
5586
5670
|
container,
|
|
5587
5671
|
rowSelector,
|
|
5588
|
-
cellSelector
|
|
5589
|
-
isRowVisible,
|
|
5590
|
-
wrap = false
|
|
5672
|
+
cellSelector
|
|
5591
5673
|
} = options;
|
|
5592
|
-
const state = createActiveItemState(callbacks, signal);
|
|
5593
|
-
/** Remembered column index for Up/Down navigation (column memory). */
|
|
5594
|
-
let rememberedCol = 0;
|
|
5595
|
-
|
|
5596
|
-
/** Check if a row element is visible (passes the isRowVisible filter). */
|
|
5597
|
-
function isVisible(row) {
|
|
5598
|
-
return !isRowVisible || isRowVisible(row);
|
|
5599
|
-
}
|
|
5600
|
-
|
|
5601
|
-
/** Check if a row is navigable (visible with at least one cell). */
|
|
5602
5674
|
function isNavigableRow(row) {
|
|
5603
5675
|
return isVisible(row) && row.querySelector(cellSelector) !== null;
|
|
5604
5676
|
}
|
|
5605
5677
|
|
|
5606
|
-
/** Create a TreeWalker scoped to row elements within the container. */
|
|
5607
|
-
function createRowWalker() {
|
|
5608
|
-
return createSelectorTreeWalker(container, rowSelector);
|
|
5609
|
-
}
|
|
5610
|
-
|
|
5611
5678
|
/**
|
|
5612
|
-
*
|
|
5613
|
-
*
|
|
5614
|
-
* - `'last'`: returns the last navigable row (or null).
|
|
5615
|
-
* - `'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`.
|
|
5616
5681
|
*/
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
let node = walker.nextNode();
|
|
5623
|
-
while (node) {
|
|
5624
|
-
if (isNavigableRow(node)) result.push(node);
|
|
5625
|
-
node = walker.nextNode();
|
|
5626
|
-
}
|
|
5627
|
-
return result;
|
|
5628
|
-
}
|
|
5629
|
-
let found = null;
|
|
5630
|
-
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();
|
|
5631
5687
|
while (node) {
|
|
5632
5688
|
if (isNavigableRow(node)) {
|
|
5633
|
-
|
|
5634
|
-
|
|
5689
|
+
const result = dive ? dive(node) : node;
|
|
5690
|
+
if (result) yield result;
|
|
5635
5691
|
}
|
|
5636
|
-
node =
|
|
5692
|
+
node = advance();
|
|
5637
5693
|
}
|
|
5638
|
-
return found;
|
|
5639
5694
|
}
|
|
5640
5695
|
|
|
5641
|
-
/**
|
|
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
|
+
}
|
|
5642
5705
|
function getRowCells(row) {
|
|
5643
5706
|
return Array.from(row.querySelectorAll(cellSelector));
|
|
5644
5707
|
}
|
|
5645
|
-
|
|
5646
|
-
/** Find the row element containing a cell, using closest(). */
|
|
5647
5708
|
function findParentRow(cell) {
|
|
5648
5709
|
const row = cell.closest(rowSelector);
|
|
5649
5710
|
return row && container.contains(row) ? row : null;
|
|
5650
5711
|
}
|
|
5651
|
-
|
|
5652
|
-
/**
|
|
5653
|
-
* Walk to the next or previous visible row (with cells) from a given row
|
|
5654
|
-
* using a TreeWalker. Avoids building the full visible rows array.
|
|
5655
|
-
*/
|
|
5656
5712
|
function findAdjacentVisibleRow(fromRow, direction) {
|
|
5657
|
-
|
|
5658
|
-
walker.currentNode = fromRow;
|
|
5659
|
-
const advance = direction === 'next' ? () => walker.nextNode() : () => walker.previousNode();
|
|
5660
|
-
let node = advance();
|
|
5661
|
-
while (node) {
|
|
5662
|
-
if (isNavigableRow(node)) return node;
|
|
5663
|
-
node = advance();
|
|
5664
|
-
}
|
|
5665
|
-
return null;
|
|
5713
|
+
return first(findRow(direction, fromRow));
|
|
5666
5714
|
}
|
|
5667
5715
|
|
|
5668
|
-
/**
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
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. */
|
|
5672
5798
|
function focusCellInRow(row, col) {
|
|
5673
5799
|
const cells = getRowCells(row);
|
|
5674
5800
|
if (cells.length === 0) return false;
|
|
@@ -5676,14 +5802,18 @@ function createGridFocusNavigation(options, callbacks, signal) {
|
|
|
5676
5802
|
state.setActive(cells[clampedCol]);
|
|
5677
5803
|
return true;
|
|
5678
5804
|
}
|
|
5805
|
+
|
|
5806
|
+
/** Got to first cell in first row */
|
|
5679
5807
|
function goToFirst() {
|
|
5680
|
-
const row =
|
|
5808
|
+
const row = findFirstVisibleRow();
|
|
5681
5809
|
if (!row) return false;
|
|
5682
5810
|
rememberedCol = 0;
|
|
5683
5811
|
return focusCellInRow(row, 0);
|
|
5684
5812
|
}
|
|
5813
|
+
|
|
5814
|
+
/** Got to first cell in last row */
|
|
5685
5815
|
function goToLast() {
|
|
5686
|
-
const row =
|
|
5816
|
+
const row = findLastVisibleRow();
|
|
5687
5817
|
if (!row) return false;
|
|
5688
5818
|
rememberedCol = 0;
|
|
5689
5819
|
return focusCellInRow(row, 0);
|
|
@@ -5708,7 +5838,7 @@ function createGridFocusNavigation(options, callbacks, signal) {
|
|
|
5708
5838
|
// Wrap to the adjacent row (or opposite boundary row), activating the first or last cell.
|
|
5709
5839
|
const rowDirection = step > 0 ? 'next' : 'prev';
|
|
5710
5840
|
const adjacentRow = findAdjacentVisibleRow(currentRow, rowDirection);
|
|
5711
|
-
const targetRow = adjacentRow ?? (step > 0 ?
|
|
5841
|
+
const targetRow = adjacentRow ?? (step > 0 ? findFirstVisibleRow() : findLastVisibleRow());
|
|
5712
5842
|
if (!targetRow) return false;
|
|
5713
5843
|
const targetCells = getRowCells(targetRow);
|
|
5714
5844
|
if (targetCells.length === 0) return false;
|
|
@@ -5730,27 +5860,17 @@ function createGridFocusNavigation(options, callbacks, signal) {
|
|
|
5730
5860
|
if (adjacentRow) return focusCellInRow(adjacentRow, rememberedCol);
|
|
5731
5861
|
if (wrap) {
|
|
5732
5862
|
// Wrap to the opposite boundary row.
|
|
5733
|
-
const wrapRow = direction === 'next' ?
|
|
5863
|
+
const wrapRow = direction === 'next' ? findFirstVisibleRow() : findLastVisibleRow();
|
|
5734
5864
|
if (wrapRow) return focusCellInRow(wrapRow, rememberedCol);
|
|
5735
5865
|
}
|
|
5736
5866
|
return false;
|
|
5737
5867
|
}
|
|
5738
5868
|
return {
|
|
5739
5869
|
type: 'grid',
|
|
5740
|
-
|
|
5741
|
-
return state.active;
|
|
5742
|
-
},
|
|
5743
|
-
get hasActiveItem() {
|
|
5744
|
-
return state.active !== null;
|
|
5745
|
-
},
|
|
5746
|
-
get hasNavigableItems() {
|
|
5747
|
-
return findVisibleRows('first') !== null;
|
|
5748
|
-
},
|
|
5749
|
-
goToFirst,
|
|
5750
|
-
goToLast,
|
|
5870
|
+
selectors,
|
|
5751
5871
|
goToOffset(offset) {
|
|
5752
5872
|
if (offset === 0) return state.active !== null;
|
|
5753
|
-
const visibleRows =
|
|
5873
|
+
const visibleRows = findAllVisibleRows();
|
|
5754
5874
|
if (visibleRows.length === 0) return false;
|
|
5755
5875
|
if (!state.active) {
|
|
5756
5876
|
// No active item: jump to first or last row, then apply remaining offset.
|
|
@@ -5769,25 +5889,6 @@ function createGridFocusNavigation(options, callbacks, signal) {
|
|
|
5769
5889
|
if (targetIdx === rowIdx) return false;
|
|
5770
5890
|
return focusCellInRow(visibleRows[targetIdx], rememberedCol);
|
|
5771
5891
|
},
|
|
5772
|
-
goToItemMatching(predicate) {
|
|
5773
|
-
// Iterate visible rows lazily — short-circuit on first match.
|
|
5774
|
-
const walker = createRowWalker();
|
|
5775
|
-
let row = walker.nextNode();
|
|
5776
|
-
while (row) {
|
|
5777
|
-
if (isNavigableRow(row)) {
|
|
5778
|
-
const cells = getRowCells(row);
|
|
5779
|
-
for (let c = 0; c < cells.length; c++) {
|
|
5780
|
-
if (predicate(cells[c])) {
|
|
5781
|
-
rememberedCol = c;
|
|
5782
|
-
state.setActive(cells[c]);
|
|
5783
|
-
return true;
|
|
5784
|
-
}
|
|
5785
|
-
}
|
|
5786
|
-
}
|
|
5787
|
-
row = walker.nextNode();
|
|
5788
|
-
}
|
|
5789
|
-
return false;
|
|
5790
|
-
},
|
|
5791
5892
|
goToItem(item) {
|
|
5792
5893
|
// Use closest() to find the parent row, then find col within that row only.
|
|
5793
5894
|
const row = findParentRow(item);
|
|
@@ -5801,6 +5902,20 @@ function createGridFocusNavigation(options, callbacks, signal) {
|
|
|
5801
5902
|
},
|
|
5802
5903
|
clear() {
|
|
5803
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();
|
|
5804
5919
|
},
|
|
5805
5920
|
goUp() {
|
|
5806
5921
|
return goVertical('prev');
|
|
@@ -5936,7 +6051,7 @@ function setupRovingTabIndex(options, signal) {
|
|
|
5936
6051
|
}
|
|
5937
6052
|
}
|
|
5938
6053
|
if (!hasTabStop) {
|
|
5939
|
-
const fallback = container.querySelector(nav.enabledItemSelector);
|
|
6054
|
+
const fallback = container.querySelector(nav.selectors.enabledItemSelector);
|
|
5940
6055
|
if (fallback) setTabIndex(fallback, '0');
|
|
5941
6056
|
}
|
|
5942
6057
|
}
|
|
@@ -5946,7 +6061,7 @@ function setupRovingTabIndex(options, signal) {
|
|
|
5946
6061
|
const items = Array.from(container.querySelectorAll(itemSelector));
|
|
5947
6062
|
const {
|
|
5948
6063
|
activeItem
|
|
5949
|
-
} = nav;
|
|
6064
|
+
} = nav.selectors;
|
|
5950
6065
|
|
|
5951
6066
|
// Prefer either the current active item (from DOM) or the current selected item
|
|
5952
6067
|
let preferredTabStopIndex = activeItem && items.indexOf(activeItem);
|
|
@@ -5963,7 +6078,7 @@ function setupRovingTabIndex(options, signal) {
|
|
|
5963
6078
|
/** Ensure a tab stop exists; find a fallback near `anchor` and optionally move focus to it. */
|
|
5964
6079
|
function ensureTabStop(shouldFocus, anchor) {
|
|
5965
6080
|
if (container.querySelector(itemActiveSelector)) return;
|
|
5966
|
-
const fallback = (anchor && nav.findNearestEnabled(anchor)) ?? container.querySelector(nav.enabledItemSelector);
|
|
6081
|
+
const fallback = (anchor && nav.selectors.findNearestEnabled(anchor)) ?? container.querySelector(nav.selectors.enabledItemSelector);
|
|
5967
6082
|
if (!fallback) return;
|
|
5968
6083
|
if (shouldFocus) {
|
|
5969
6084
|
nav.goToItem(fallback);
|
|
@@ -6036,7 +6151,7 @@ function setupRovingTabIndex(options, signal) {
|
|
|
6036
6151
|
|
|
6037
6152
|
// Handle disabled
|
|
6038
6153
|
if (disabledTargets.length > 0) {
|
|
6039
|
-
const currentActive = nav.activeItem;
|
|
6154
|
+
const currentActive = nav.selectors.activeItem;
|
|
6040
6155
|
for (const target of disabledTargets) {
|
|
6041
6156
|
setTabIndex(target, '-1');
|
|
6042
6157
|
}
|
|
@@ -6068,7 +6183,7 @@ function setupRovingTabIndex(options, signal) {
|
|
|
6068
6183
|
|
|
6069
6184
|
container.addEventListener('keydown', evt => {
|
|
6070
6185
|
// Adopt focus target if nothing is active yet.
|
|
6071
|
-
if (!nav.activeItem) {
|
|
6186
|
+
if (!nav.selectors.activeItem) {
|
|
6072
6187
|
const target = evt.target;
|
|
6073
6188
|
if (target.matches(itemSelector) && container.contains(target)) {
|
|
6074
6189
|
nav.goToItem(target);
|
|
@@ -6089,10 +6204,10 @@ function setupRovingTabIndex(options, signal) {
|
|
|
6089
6204
|
handled = nav.goUp();
|
|
6090
6205
|
break;
|
|
6091
6206
|
case 'Home':
|
|
6092
|
-
handled = nav.
|
|
6207
|
+
handled = nav.goTo(s => s.getFirst());
|
|
6093
6208
|
break;
|
|
6094
6209
|
case 'End':
|
|
6095
|
-
handled = nav.
|
|
6210
|
+
handled = nav.goTo(s => s.getLast());
|
|
6096
6211
|
break;
|
|
6097
6212
|
}
|
|
6098
6213
|
if (handled) {
|
|
@@ -6253,12 +6368,24 @@ SelectionChipGroup.className = CLASSNAME$1j;
|
|
|
6253
6368
|
/**
|
|
6254
6369
|
* Get the value for a combobox option element.
|
|
6255
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
|
|
6256
6373
|
*/
|
|
6257
6374
|
function getOptionValue(option) {
|
|
6258
6375
|
if (option.dataset.value !== undefined) return option.dataset.value;
|
|
6259
6376
|
return option.textContent?.trim() ?? '';
|
|
6260
6377
|
}
|
|
6261
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
|
+
|
|
6262
6389
|
/** Returns true when an option carries aria-disabled="true". */
|
|
6263
6390
|
function isOptionDisabled(option) {
|
|
6264
6391
|
return option.getAttribute('aria-disabled') === 'true';
|
|
@@ -6270,20 +6397,8 @@ function isActionCell(cell) {
|
|
|
6270
6397
|
if (!row) return false;
|
|
6271
6398
|
return row.querySelector('[role="gridcell"]') !== cell;
|
|
6272
6399
|
}
|
|
6273
|
-
|
|
6274
|
-
/** Predicate matching an option element that carries `aria-selected="true"`. */
|
|
6275
6400
|
const isSelected = el => el.getAttribute('aria-selected') === 'true';
|
|
6276
6401
|
|
|
6277
|
-
/** Navigate to the selected option, or to the first option if none is selected. */
|
|
6278
|
-
function goToSelectedOrFirst(nav) {
|
|
6279
|
-
if (!nav.goToItemMatching(isSelected)) nav.goToFirst();
|
|
6280
|
-
}
|
|
6281
|
-
|
|
6282
|
-
/** Navigate to the selected option, or to the last option if none is selected. */
|
|
6283
|
-
function goToSelectedOrLast(nav) {
|
|
6284
|
-
if (!nav.goToItemMatching(isSelected)) nav.goToLast();
|
|
6285
|
-
}
|
|
6286
|
-
|
|
6287
6402
|
/**
|
|
6288
6403
|
* Compute the current state of a section and notify when it changed.
|
|
6289
6404
|
*
|
|
@@ -6596,12 +6711,12 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
|
|
|
6596
6711
|
const nav = handle.focusNav;
|
|
6597
6712
|
switch (event.key) {
|
|
6598
6713
|
case 'Enter':
|
|
6599
|
-
if (handle.isOpen && nav?.
|
|
6714
|
+
if (handle.isOpen && nav?.selectors.activeItem) {
|
|
6600
6715
|
// Capture activeItem before click — the click handler may close
|
|
6601
6716
|
// the popover and clear the focus navigation state.
|
|
6602
6717
|
const {
|
|
6603
6718
|
activeItem
|
|
6604
|
-
} = nav;
|
|
6719
|
+
} = nav.selectors;
|
|
6605
6720
|
// "Click" on active option
|
|
6606
6721
|
if (!isOptionDisabled(activeItem)) {
|
|
6607
6722
|
activeItem.click();
|
|
@@ -6620,39 +6735,35 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
|
|
|
6620
6735
|
// let Enter pass through so it can submit a surrounding form
|
|
6621
6736
|
break;
|
|
6622
6737
|
case 'ArrowDown':
|
|
6623
|
-
if (
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
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());
|
|
6633
6747
|
}
|
|
6634
6748
|
} else {
|
|
6635
|
-
|
|
6636
|
-
handle.setIsOpen(true);
|
|
6637
|
-
if (!altKey) goToSelectedOrFirst(nav);
|
|
6749
|
+
nav.goTo(s => s.getMatching(isSelected) ?? s.getFirst());
|
|
6638
6750
|
}
|
|
6639
6751
|
}
|
|
6640
6752
|
flag = true;
|
|
6641
6753
|
break;
|
|
6642
6754
|
case 'ArrowUp':
|
|
6643
|
-
if (
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
} 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) {
|
|
6649
6760
|
if (nav.type === 'grid') {
|
|
6650
6761
|
nav.goUp();
|
|
6651
6762
|
} else if (!nav.goToOffset(-1) && wrapNavigation) {
|
|
6652
|
-
nav.
|
|
6763
|
+
nav.goTo(s => s.getLast());
|
|
6653
6764
|
}
|
|
6654
|
-
} else if (
|
|
6655
|
-
|
|
6765
|
+
} else if (!altKey) {
|
|
6766
|
+
nav.goTo(s => s.getMatching(isSelected) ?? s.getLast());
|
|
6656
6767
|
}
|
|
6657
6768
|
}
|
|
6658
6769
|
flag = true;
|
|
@@ -6667,13 +6778,13 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
|
|
|
6667
6778
|
flag = true;
|
|
6668
6779
|
break;
|
|
6669
6780
|
case 'PageUp':
|
|
6670
|
-
if (handle.isOpen && nav?.
|
|
6781
|
+
if (handle.isOpen && nav?.selectors.activeItem) {
|
|
6671
6782
|
nav.goToOffset(-10);
|
|
6672
6783
|
}
|
|
6673
6784
|
flag = true;
|
|
6674
6785
|
break;
|
|
6675
6786
|
case 'PageDown':
|
|
6676
|
-
if (handle.isOpen && nav?.
|
|
6787
|
+
if (handle.isOpen && nav?.selectors.activeItem) {
|
|
6677
6788
|
nav.goToOffset(10);
|
|
6678
6789
|
}
|
|
6679
6790
|
flag = true;
|
|
@@ -6759,25 +6870,16 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
|
|
|
6759
6870
|
// Update aria-expanded on trigger
|
|
6760
6871
|
trigger?.setAttribute('aria-expanded', String(isOpen));
|
|
6761
6872
|
notify('open', isOpen);
|
|
6762
|
-
|
|
6763
|
-
// When opening, always notify the current options state so that
|
|
6764
|
-
// subscribers (ComboboxState) get the correct initial value.
|
|
6765
|
-
// Without this, a list that starts empty never fires `optionsChange`
|
|
6766
|
-
// because `lastOptionsLength` is initialized to `0` and `notifyVisibilityChange`
|
|
6767
|
-
// only fires on *changes*.
|
|
6768
|
-
if (isOpen) {
|
|
6769
|
-
const inputValue = trigger?.value ?? '';
|
|
6770
|
-
notify('optionsChange', {
|
|
6771
|
-
optionsLength: lastOptionsLength,
|
|
6772
|
-
inputValue
|
|
6773
|
-
});
|
|
6774
|
-
}
|
|
6775
6873
|
},
|
|
6776
6874
|
select(option) {
|
|
6777
6875
|
callbacks.onSelect?.({
|
|
6778
6876
|
value: option ? getOptionValue(option) : ''
|
|
6779
6877
|
});
|
|
6780
6878
|
},
|
|
6879
|
+
flushPendingNavigation() {
|
|
6880
|
+
// Do navigations actions we could not do because the combobox items were not mounted yet
|
|
6881
|
+
focusNav?.flushPendingNavigation();
|
|
6882
|
+
},
|
|
6781
6883
|
registerOption(element, callback) {
|
|
6782
6884
|
const filterLower = filterValue.toLowerCase();
|
|
6783
6885
|
const text = getOptionValue(element).toLowerCase();
|
|
@@ -6950,18 +7052,9 @@ function createTypeahead(getWalker, getItemValue, signal) {
|
|
|
6950
7052
|
}
|
|
6951
7053
|
signal.addEventListener('abort', reset);
|
|
6952
7054
|
|
|
6953
|
-
//
|
|
6954
|
-
function
|
|
6955
|
-
|
|
6956
|
-
if (searchTimeout !== undefined) {
|
|
6957
|
-
clearTimeout(searchTimeout);
|
|
6958
|
-
}
|
|
6959
|
-
|
|
6960
|
-
// Accumulate the character.
|
|
6961
|
-
searchString += key.toLowerCase();
|
|
6962
|
-
|
|
6963
|
-
// Schedule clearing the search string after inactivity.
|
|
6964
|
-
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;
|
|
6965
7058
|
const walker = getWalker();
|
|
6966
7059
|
if (!walker) return null;
|
|
6967
7060
|
|
|
@@ -7022,8 +7115,29 @@ function createTypeahead(getWalker, getItemValue, signal) {
|
|
|
7022
7115
|
}
|
|
7023
7116
|
}
|
|
7024
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
|
+
}
|
|
7025
7138
|
return {
|
|
7026
7139
|
handle,
|
|
7140
|
+
rematch,
|
|
7027
7141
|
reset
|
|
7028
7142
|
};
|
|
7029
7143
|
}
|
|
@@ -7060,7 +7174,7 @@ function setupComboboxButton(button, callbacks) {
|
|
|
7060
7174
|
// In list mode, walk option elements.
|
|
7061
7175
|
const selector = combobox.focusNav?.type === 'grid' ? '[role="gridcell"]' : '[role="option"]';
|
|
7062
7176
|
return createSelectorTreeWalker(combobox.listbox, selector);
|
|
7063
|
-
},
|
|
7177
|
+
}, getOptionLabel, signal);
|
|
7064
7178
|
|
|
7065
7179
|
// Click toggles the listbox.
|
|
7066
7180
|
button.addEventListener('click', () => combobox.setIsOpen(!combobox.isOpen), {
|
|
@@ -7071,59 +7185,65 @@ function setupComboboxButton(button, callbacks) {
|
|
|
7071
7185
|
switch (event.key) {
|
|
7072
7186
|
case 'Tab':
|
|
7073
7187
|
// Selects the focused option
|
|
7074
|
-
if (combobox.isOpen && nav?.
|
|
7075
|
-
combobox.select(nav.activeItem);
|
|
7188
|
+
if (combobox.isOpen && nav?.selectors.activeItem) {
|
|
7189
|
+
combobox.select(nav.selectors.activeItem);
|
|
7076
7190
|
}
|
|
7077
7191
|
// Return false to continue normal 'Tab' behavior (focus next).
|
|
7078
7192
|
return false;
|
|
7079
7193
|
case ' ':
|
|
7080
7194
|
// Space acts like Enter in button mode.
|
|
7081
|
-
if (combobox.isOpen && nav?.
|
|
7195
|
+
if (combobox.isOpen && nav?.selectors.activeItem) {
|
|
7082
7196
|
// Click the active item — delegated handler handles select + close.
|
|
7083
|
-
nav.activeItem.click();
|
|
7197
|
+
nav.selectors.activeItem.click();
|
|
7084
7198
|
} else {
|
|
7085
7199
|
combobox.setIsOpen(true);
|
|
7086
7200
|
}
|
|
7087
7201
|
return true;
|
|
7088
7202
|
case 'ArrowUp':
|
|
7089
7203
|
// Alt+ArrowUp: select the focused option and close.
|
|
7090
|
-
if (event.altKey && combobox.isOpen && nav?.
|
|
7091
|
-
combobox.select(nav.activeItem);
|
|
7204
|
+
if (event.altKey && combobox.isOpen && nav?.selectors.activeItem) {
|
|
7205
|
+
combobox.select(nav.selectors.activeItem);
|
|
7092
7206
|
combobox.setIsOpen(false);
|
|
7093
7207
|
return true;
|
|
7094
7208
|
}
|
|
7095
7209
|
// All other ArrowUp cases handled by base handler.
|
|
7096
7210
|
return false;
|
|
7097
7211
|
case 'Home':
|
|
7212
|
+
// `goTo` focuses the first option immediately when open, or defers
|
|
7213
|
+
// until the options commit when opening from closed.
|
|
7098
7214
|
combobox.setIsOpen(true);
|
|
7099
|
-
nav?.
|
|
7215
|
+
nav?.goTo(n => n.getFirst());
|
|
7100
7216
|
return true;
|
|
7101
7217
|
case 'End':
|
|
7102
7218
|
combobox.setIsOpen(true);
|
|
7103
|
-
nav?.
|
|
7219
|
+
nav?.goTo(n => n.getLast());
|
|
7104
7220
|
return true;
|
|
7105
7221
|
case 'ArrowLeft':
|
|
7106
7222
|
// Grid mode: navigate to previous cell.
|
|
7107
|
-
if (nav?.type === 'grid' && combobox.isOpen && nav.
|
|
7223
|
+
if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
|
|
7108
7224
|
nav.goLeft();
|
|
7109
7225
|
return true;
|
|
7110
7226
|
}
|
|
7111
7227
|
return false;
|
|
7112
7228
|
case 'ArrowRight':
|
|
7113
7229
|
// Grid mode: navigate to next cell.
|
|
7114
|
-
if (nav?.type === 'grid' && combobox.isOpen && nav.
|
|
7230
|
+
if (nav?.type === 'grid' && combobox.isOpen && nav.selectors.activeItem) {
|
|
7115
7231
|
nav.goRight();
|
|
7116
7232
|
return true;
|
|
7117
7233
|
}
|
|
7118
7234
|
return false;
|
|
7235
|
+
case 'Escape':
|
|
7236
|
+
// Close if open; never clear selection (button-mode has no text input).
|
|
7237
|
+
if (combobox.isOpen) {
|
|
7238
|
+
combobox.setIsOpen(false);
|
|
7239
|
+
}
|
|
7240
|
+
return true;
|
|
7119
7241
|
default:
|
|
7120
7242
|
// Printable characters → typeahead.
|
|
7121
7243
|
if (isPrintableKey(event)) {
|
|
7122
7244
|
combobox.setIsOpen(true);
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
nav.goToItem(match);
|
|
7126
|
-
}
|
|
7245
|
+
typeahead.handle(event.key, nav?.selectors.activeItem ?? null);
|
|
7246
|
+
nav?.goTo(n => typeahead.rematch(n.activeItem));
|
|
7127
7247
|
return true;
|
|
7128
7248
|
}
|
|
7129
7249
|
return false;
|
|
@@ -7286,9 +7406,10 @@ const ComboboxButton = Object.assign(forwardRefPolymorphic((props, ref) => {
|
|
|
7286
7406
|
|
|
7287
7407
|
// If `as` is provided, use it directly; otherwise use the LumX Button (full theme/disabled behavior).
|
|
7288
7408
|
const ButtonComp = as ?? Button;
|
|
7289
|
-
|
|
7290
|
-
//
|
|
7291
|
-
const
|
|
7409
|
+
|
|
7410
|
+
// Track the button DOM element via state (instead of useRef) so it re-triggers the useEffect
|
|
7411
|
+
const [buttonElement, setButtonElement] = useState(null);
|
|
7412
|
+
const mergedRef = useMergeRefs(ref, setButtonElement, anchorRef);
|
|
7292
7413
|
|
|
7293
7414
|
// Keep onSelect in a ref to avoid re-creating the handle on every render
|
|
7294
7415
|
const onSelectRef = useRef(onSelect);
|
|
@@ -7296,9 +7417,8 @@ const ComboboxButton = Object.assign(forwardRefPolymorphic((props, ref) => {
|
|
|
7296
7417
|
|
|
7297
7418
|
// Create the combobox handle with button-mode controller on mount
|
|
7298
7419
|
useEffect(() => {
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
const handle = setupComboboxButton(button, {
|
|
7420
|
+
if (!buttonElement) return undefined;
|
|
7421
|
+
const handle = setupComboboxButton(buttonElement, {
|
|
7302
7422
|
onSelect(option) {
|
|
7303
7423
|
onSelectRef.current?.(option);
|
|
7304
7424
|
}
|
|
@@ -7308,7 +7428,7 @@ const ComboboxButton = Object.assign(forwardRefPolymorphic((props, ref) => {
|
|
|
7308
7428
|
handle.destroy();
|
|
7309
7429
|
setHandle(null);
|
|
7310
7430
|
};
|
|
7311
|
-
}, [setHandle]);
|
|
7431
|
+
}, [buttonElement, setHandle]);
|
|
7312
7432
|
return ComboboxButton$1({
|
|
7313
7433
|
...buttonProps,
|
|
7314
7434
|
label,
|
|
@@ -7447,7 +7567,7 @@ function setupComboboxInput(input, options) {
|
|
|
7447
7567
|
case 'ArrowLeft':
|
|
7448
7568
|
case 'ArrowRight':
|
|
7449
7569
|
// Grid mode: navigate cells when active item exists.
|
|
7450
|
-
if (nav?.type === 'grid' && nav.
|
|
7570
|
+
if (nav?.type === 'grid' && nav.selectors.activeItem) {
|
|
7451
7571
|
if (event.key === 'ArrowLeft') nav.goLeft();else nav.goRight();
|
|
7452
7572
|
return true;
|
|
7453
7573
|
}
|
|
@@ -8306,25 +8426,30 @@ const ComboboxList = forwardRef((props, ref) => {
|
|
|
8306
8426
|
const listContextValue = useMemo(() => ({
|
|
8307
8427
|
type
|
|
8308
8428
|
}), [type]);
|
|
8429
|
+
const [isOpen] = useComboboxOpen();
|
|
8430
|
+
const options = useComboboxEvent('optionsChange', undefined);
|
|
8431
|
+
const visibleCount = options?.optionsLength ?? 0;
|
|
8309
8432
|
|
|
8310
|
-
// Register
|
|
8433
|
+
// Register list as listbox when handle is available.
|
|
8311
8434
|
useEffect(() => {
|
|
8312
8435
|
const list = internalRef.current;
|
|
8313
8436
|
if (!list) return undefined;
|
|
8314
8437
|
return handle?.registerListbox(list);
|
|
8315
8438
|
}, [handle]);
|
|
8316
8439
|
|
|
8317
|
-
// Track loading state for aria-busy
|
|
8318
|
-
// Uses both the handle's synchronous getter (for initial state) and the loadingChange event
|
|
8319
|
-
// (for subsequent updates), because child useEffects (skeleton registration) run before
|
|
8320
|
-
// parent subscriptions — relying on events alone would miss the initial notification.
|
|
8440
|
+
// Track loading state for aria-busy
|
|
8321
8441
|
const [isLoading, setIsLoading] = useState(false);
|
|
8322
8442
|
useEffect(() => {
|
|
8323
8443
|
if (!handle) return undefined;
|
|
8324
|
-
// Read current state synchronously (catches registrations
|
|
8444
|
+
// Read current state synchronously (catches registrations before subscription).
|
|
8325
8445
|
setIsLoading(handle.isLoading);
|
|
8326
8446
|
return handle.subscribe('loadingChange', setIsLoading);
|
|
8327
8447
|
}, [handle]);
|
|
8448
|
+
|
|
8449
|
+
// Flush pending keyboard navigation after options commit on open.
|
|
8450
|
+
useLayoutEffect(() => {
|
|
8451
|
+
if (isOpen) handle?.flushPendingNavigation();
|
|
8452
|
+
}, [isOpen, visibleCount, handle]);
|
|
8328
8453
|
return /*#__PURE__*/jsx(ComboboxListContext.Provider, {
|
|
8329
8454
|
value: listContextValue,
|
|
8330
8455
|
children: ComboboxList$1({
|
|
@@ -8335,7 +8460,7 @@ const ComboboxList = forwardRef((props, ref) => {
|
|
|
8335
8460
|
ref: mergedRef,
|
|
8336
8461
|
id: listboxId,
|
|
8337
8462
|
type,
|
|
8338
|
-
children
|
|
8463
|
+
children: isOpen ? children : null
|
|
8339
8464
|
})
|
|
8340
8465
|
});
|
|
8341
8466
|
});
|
|
@@ -16825,6 +16950,24 @@ function toggleSelection(options, getOptionId, currentValue, selectedOptionId, i
|
|
|
16825
16950
|
return updated;
|
|
16826
16951
|
}
|
|
16827
16952
|
|
|
16953
|
+
/**
|
|
16954
|
+
* Get the display name for a single option value.
|
|
16955
|
+
*
|
|
16956
|
+
* Resolves the option's display name by trying `getOptionName` first,
|
|
16957
|
+
* then falling back to `getOptionId`, returning `''` for nullish values.
|
|
16958
|
+
*/
|
|
16959
|
+
function getOptionDisplayName(value, getOptionName, getOptionId) {
|
|
16960
|
+
if (value === undefined || value === null) return '';
|
|
16961
|
+
if (getOptionName) {
|
|
16962
|
+
const name = getWithSelector(getOptionName, value);
|
|
16963
|
+
if (name != null) return String(name);
|
|
16964
|
+
}
|
|
16965
|
+
if (getOptionId) {
|
|
16966
|
+
return String(getWithSelector(getOptionId, value));
|
|
16967
|
+
}
|
|
16968
|
+
return '';
|
|
16969
|
+
}
|
|
16970
|
+
|
|
16828
16971
|
/**
|
|
16829
16972
|
* Render options as ComboboxOption elements.
|
|
16830
16973
|
* Framework-specific components are passed as a second argument.
|
|
@@ -16949,7 +17092,7 @@ const SelectButton$2 = (props, {
|
|
|
16949
17092
|
* each option to its name and join with `, `. Falsy entries (undefined, empty names)
|
|
16950
17093
|
* are filtered out so partial state still renders cleanly.
|
|
16951
17094
|
*/
|
|
16952
|
-
const displayValue = value != null ? castArray(value).map(v => v
|
|
17095
|
+
const displayValue = value != null ? castArray(value).map(v => getOptionDisplayName(v, getOptionName, getOptionId)).filter(Boolean).join(', ') : '';
|
|
16953
17096
|
return /*#__PURE__*/jsxs(Combobox.Provider, {
|
|
16954
17097
|
onOpen: onOpen,
|
|
16955
17098
|
children: [/*#__PURE__*/jsx(Combobox.Button, {
|
|
@@ -17078,8 +17221,8 @@ const DefaultButton = forwardRef((props, ref) => /*#__PURE__*/jsx(Button, {
|
|
|
17078
17221
|
* fully replaced and only props valid for that component apply.
|
|
17079
17222
|
*
|
|
17080
17223
|
* Discriminated on `selectionType`:
|
|
17081
|
-
* - default / `'single'` → `value?: O`, `onChange?: (newValue
|
|
17082
|
-
* - `'multiple'` → `value?: O[]`, `onChange?: (newValue
|
|
17224
|
+
* - default / `'single'` → `value?: O`, `onChange?: (newValue: O) => void`.
|
|
17225
|
+
* - `'multiple'` → `value?: O[]`, `onChange?: (newValue: O[]) => void`.
|
|
17083
17226
|
*
|
|
17084
17227
|
* `as` and `selectionType` are top-level on this type (rather than buried in
|
|
17085
17228
|
* an intersection or union member) so that TS can infer `E` from `as` and
|
|
@@ -17092,10 +17235,11 @@ const DefaultButton = forwardRef((props, ref) => /*#__PURE__*/jsx(Button, {
|
|
|
17092
17235
|
|
|
17093
17236
|
/**
|
|
17094
17237
|
* Single-selection props (`selectionType` defaults to `'single'`).
|
|
17095
|
-
* Backwards-compatible alias — existing consumers do not need to set `selectionType`.
|
|
17096
17238
|
*/
|
|
17097
17239
|
|
|
17098
|
-
/**
|
|
17240
|
+
/**
|
|
17241
|
+
* Multi-selection props (`selectionType: 'multiple'` is required to opt in).
|
|
17242
|
+
*/
|
|
17099
17243
|
|
|
17100
17244
|
/**
|
|
17101
17245
|
* `React.forwardRef` re-typed to preserve our polymorphic generics
|
|
@@ -17139,6 +17283,9 @@ const SelectButton$1 = React__default.forwardRef((props, ref) => {
|
|
|
17139
17283
|
const next = toggleSelection(options, getOptionId, value, selectedOption?.value, isMultiple);
|
|
17140
17284
|
onChange?.(next);
|
|
17141
17285
|
}, [getOptionId, isMultiple, onChange, options, value]);
|
|
17286
|
+
|
|
17287
|
+
// If as is defined and not the Button, render as, else render DefaultButton (with mdiMenuDown right icon)
|
|
17288
|
+
const buttonAs = as && as !== Button ? as : DefaultButton;
|
|
17142
17289
|
return SelectButton$2({
|
|
17143
17290
|
options,
|
|
17144
17291
|
getOptionId,
|
|
@@ -17151,10 +17298,9 @@ const SelectButton$1 = React__default.forwardRef((props, ref) => {
|
|
|
17151
17298
|
isMultiselectable: isMultiple,
|
|
17152
17299
|
label,
|
|
17153
17300
|
labelDisplayMode,
|
|
17154
|
-
// With no `as`, the default trigger adds the chevron.
|
|
17155
17301
|
buttonProps: {
|
|
17156
17302
|
...buttonProps,
|
|
17157
|
-
as:
|
|
17303
|
+
as: buttonAs,
|
|
17158
17304
|
ref
|
|
17159
17305
|
},
|
|
17160
17306
|
popoverProps,
|
|
@@ -17185,24 +17331,6 @@ const SelectButton = Object.assign(SelectButton$1, {
|
|
|
17185
17331
|
Option: ComboboxOption
|
|
17186
17332
|
});
|
|
17187
17333
|
|
|
17188
|
-
/**
|
|
17189
|
-
* Get the display name for a single option value.
|
|
17190
|
-
*
|
|
17191
|
-
* Resolves the option's display name by trying `getOptionName` first,
|
|
17192
|
-
* then falling back to `getOptionId`, returning `''` for nullish values.
|
|
17193
|
-
*/
|
|
17194
|
-
function getOptionDisplayName(value, getOptionName, getOptionId) {
|
|
17195
|
-
if (value === undefined || value === null) return '';
|
|
17196
|
-
if (getOptionName) {
|
|
17197
|
-
const name = getWithSelector(getOptionName, value);
|
|
17198
|
-
if (name != null) return String(name);
|
|
17199
|
-
}
|
|
17200
|
-
if (getOptionId) {
|
|
17201
|
-
return String(getWithSelector(getOptionId, value));
|
|
17202
|
-
}
|
|
17203
|
-
return '';
|
|
17204
|
-
}
|
|
17205
|
-
|
|
17206
17334
|
/**
|
|
17207
17335
|
* Component display name.
|
|
17208
17336
|
*/
|