@lumx/react 4.12.0 → 4.12.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.d.ts +108 -69
  2. package/index.js +195 -102
  3. package/index.js.map +1 -1
  4. package/package.json +4 -3
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ColorVariant as ColorVariant$1, Size as Size$1, VISUALLY_HIDDEN, Theme as Theme$1, AspectRatio as AspectRatio$1, DOCUMENT, IS_BROWSER as IS_BROWSER$1, Emphasis as Emphasis$1, WINDOW, DIALOG_TRANSITION_DURATION, Orientation as Orientation$1, NOTIFICATION_TRANSITION_DURATION, Kind as Kind$1, Alignment as Alignment$1, ColorPalette as ColorPalette$1 } from '@lumx/core/js/constants';
1
+ import { ColorVariant as ColorVariant$1, Size as Size$1, VISUALLY_HIDDEN, Theme as Theme$1, AspectRatio as AspectRatio$1, DOCUMENT as DOCUMENT$1, IS_BROWSER as IS_BROWSER$1, Emphasis as Emphasis$1, WINDOW, DIALOG_TRANSITION_DURATION, Orientation as Orientation$1, NOTIFICATION_TRANSITION_DURATION, Kind as Kind$1, Alignment as Alignment$1, ColorPalette as ColorPalette$1 } from '@lumx/core/js/constants';
2
2
  export * from '@lumx/core/js/constants';
3
3
  export * from '@lumx/core/js/types';
4
4
  import * as React from 'react';
@@ -204,6 +204,11 @@ const ColorVariant = {
204
204
  */
205
205
  const IS_BROWSER = typeof window !== 'undefined' && !window.navigator.userAgent.includes('jsdom');
206
206
 
207
+ /**
208
+ * Optional global `document` instance (not defined when running SSR).
209
+ */
210
+ const DOCUMENT = typeof document !== 'undefined' ? document : undefined;
211
+
207
212
  function getDefaultExportFromCjs (x) {
208
213
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
209
214
  }
@@ -2519,23 +2524,29 @@ const isChipDisabled = chip => chip?.getAttribute('aria-disabled') === 'true';
2519
2524
  */
2520
2525
 
2521
2526
  /**
2522
- * Finds the previous enabled chip before the given chip element using a TreeWalker.
2523
- * If no currentChip is provided, returns the last enabled chip in the container.
2527
+ * Find the nearest enabled chip in the given direction relative to `anchor`.
2528
+ *
2529
+ * - When `anchor` is provided, walks one step in `direction` from it.
2530
+ * - When `anchor` is omitted, returns the first enabled chip from the matching
2531
+ * end of the container (last chip for 'previous', first for 'next').
2524
2532
  */
2525
- function findPreviousChip(container, currentChip) {
2533
+ function findSiblingChip(container, direction, anchor) {
2526
2534
  const walker = createSelectorTreeWalker(container, ENABLED_CHIP_SELECTOR);
2527
- if (!currentChip) {
2528
- // Find the last enabled chip by walking to the end.
2535
+ if (anchor) {
2536
+ walker.currentNode = anchor;
2537
+ const node = direction === 'next' ? walker.nextNode() : walker.previousNode();
2538
+ return node || undefined;
2539
+ }
2540
+
2541
+ // No anchor: walk from the matching end of the container.
2542
+ if (direction === 'previous') {
2529
2543
  let last;
2530
2544
  while (walker.nextNode()) {
2531
2545
  last = walker.currentNode;
2532
2546
  }
2533
2547
  return last;
2534
2548
  }
2535
-
2536
- // Position the walker at the current chip and walk backward.
2537
- walker.currentNode = currentChip;
2538
- return walker.previousNode() || undefined;
2549
+ return walker.nextNode() || undefined;
2539
2550
  }
2540
2551
 
2541
2552
  /** Remove an option by its id and call onChange. */
@@ -2574,9 +2585,6 @@ function setupSelectionChipGroupEvents(options) {
2574
2585
  };
2575
2586
 
2576
2587
  // Delegated keydown handler on the chip group container.
2577
- // Enter/Space trigger removal (like click).
2578
- // Backspace also removes but explicitly moves focus to previous chip (overriding the
2579
- // roving tabindex MutationObserver which defaults to moving focus forward).
2580
2588
  const handleKeyDown = evt => {
2581
2589
  const chip = getChip(evt.target);
2582
2590
  const optionId = chip?.dataset.optionId;
@@ -2584,14 +2592,20 @@ function setupSelectionChipGroupEvents(options) {
2584
2592
  if (optionId == null || !activatingKey || isChipDisabled(chip)) {
2585
2593
  return;
2586
2594
  }
2595
+
2596
+ // Compute focus fallback target before removing the chip.
2597
+ let focusTarget;
2587
2598
  if (evt.key === 'Backspace') {
2588
- // Move focus to previous option (instead of next in listbox)
2589
- const previousChip = findPreviousChip(container, chip);
2590
- const focusTarget = previousChip || options.getInput?.();
2591
- if (focusTarget) {
2592
- focusTarget.focus();
2593
- focusTarget.setAttribute('tabindex', '0');
2594
- }
2599
+ // Custom behavior (not WAI-ARIA recommendation) => focus the previous chip, fallback on input (no more chips)
2600
+ focusTarget = findSiblingChip(container, 'previous', chip) || options.getInput?.() || undefined;
2601
+ } else {
2602
+ // WAI-ARIA recommendation when removing an option in a listbox => focus the next chip, fallback on previous chip
2603
+ // (bonus: we fallback on input when there is no more chips)
2604
+ focusTarget = findSiblingChip(container, 'next', chip) || findSiblingChip(container, 'previous', chip) || options.getInput?.() || undefined;
2605
+ }
2606
+ if (focusTarget) {
2607
+ focusTarget.focus();
2608
+ focusTarget.setAttribute('tabindex', '0');
2595
2609
  }
2596
2610
  evt.preventDefault();
2597
2611
  removeOption(options, optionId);
@@ -2609,7 +2623,7 @@ function setupSelectionChipGroupEvents(options) {
2609
2623
  if (!backspacePressed || !cursorAtStart) return;
2610
2624
  evt.stopPropagation();
2611
2625
  evt.preventDefault();
2612
- const lastChip = findPreviousChip(container);
2626
+ const lastChip = findSiblingChip(container, 'previous');
2613
2627
  lastChip?.focus();
2614
2628
  };
2615
2629
  input.addEventListener('keydown', inputHandler);
@@ -5051,7 +5065,7 @@ const LISTENERS = makeListenerTowerContext();
5051
5065
  */
5052
5066
  function useCallbackOnEscape(callback, closeOnEscape = true) {
5053
5067
  useEffect(() => {
5054
- const rootElement = DOCUMENT?.body;
5068
+ const rootElement = DOCUMENT$1?.body;
5055
5069
  if (!closeOnEscape || !callback || !rootElement) {
5056
5070
  return undefined;
5057
5071
  }
@@ -5266,7 +5280,7 @@ const Tooltip = forwardRef((props, ref) => {
5266
5280
  ...forwardedProps
5267
5281
  } = props;
5268
5282
  // Disable in SSR.
5269
- if (!DOCUMENT) {
5283
+ if (!DOCUMENT$1) {
5270
5284
  return /*#__PURE__*/jsx(Fragment, {
5271
5285
  children: children
5272
5286
  });
@@ -6770,9 +6784,9 @@ function setupCombobox(callbacks, options, onTriggerAttach) {
6770
6784
  }
6771
6785
  },
6772
6786
  select(option) {
6773
- callbacks.onSelect({
6787
+ callbacks.onSelect?.({
6774
6788
  value: option ? getOptionValue(option) : ''
6775
- }, handle);
6789
+ });
6776
6790
  },
6777
6791
  registerOption(element, callback) {
6778
6792
  const filterLower = filterValue.toLowerCase();
@@ -7334,6 +7348,7 @@ const ComboboxButton = Object.assign(forwardRefPolymorphic((props, ref) => {
7334
7348
  * @returns A ComboboxHandle for interacting with the combobox.
7335
7349
  */
7336
7350
  function setupComboboxInput(input, options) {
7351
+ let handle;
7337
7352
  const {
7338
7353
  filter = 'auto',
7339
7354
  onSelect: optionOnSelect
@@ -7355,21 +7370,21 @@ function setupComboboxInput(input, options) {
7355
7370
  * Wraps the consumer's onSelect to perform input-mode side effects after selection:
7356
7371
  * clears the active descendant, resets the filter, and re-opens the popup.
7357
7372
  */
7358
- const onSelect = (option, combobox) => {
7359
- optionOnSelect(option, combobox);
7373
+ const onSelect = option => {
7374
+ optionOnSelect?.(option);
7360
7375
 
7361
7376
  // Clear the active item. In multi-select, keep visual focus so the
7362
7377
  // user can continue navigating after selection.
7363
- if (!combobox.isMultiSelect) {
7364
- combobox.focusNav?.clear();
7378
+ if (!handle.isMultiSelect) {
7379
+ handle.focusNav?.clear();
7365
7380
  }
7366
7381
  userHasTyped = false;
7367
- combobox.setIsOpen(true);
7382
+ handle.setIsOpen(true);
7368
7383
  if (autoFilter) {
7369
- combobox.setFilter('');
7384
+ handle.setFilter('');
7370
7385
  }
7371
7386
  };
7372
- const handle = setupCombobox({
7387
+ handle = setupCombobox({
7373
7388
  onSelect
7374
7389
  }, {
7375
7390
  wrapNavigation: true
@@ -8803,8 +8818,14 @@ const ComboboxOptionMoreInfo$1 = (props, {
8803
8818
  /** CSS selector listing all tabbable elements. */
8804
8819
  const TABBABLE_ELEMENTS_SELECTOR = 'a[href], button, textarea, input:not([type="hidden"]):not([hidden]), [tabindex]';
8805
8820
 
8806
- /** CSS selector matching element that are disabled (should not receive focus). */
8807
- const DISABLED_SELECTOR = '[hidden], [tabindex="-1"], [disabled]:not([disabled="false"]), [aria-disabled]:not([aria-disabled="false"])';
8821
+ /**
8822
+ * CSS selector matching elements that should be excluded from focus traversal.
8823
+ *
8824
+ * Note: `aria-disabled` is intentionally NOT in this list — per ARIA semantics, an `aria-disabled` element
8825
+ * remains focusable (and discoverable by assistive tech). To remove an element from the tab order, use
8826
+ * `tabindex="-1"` instead.
8827
+ */
8828
+ const DISABLED_SELECTOR = '[hidden], [tabindex="-1"], [disabled]:not([disabled="false"])';
8808
8829
 
8809
8830
  const isNotDisabled = element => !element.matches(DISABLED_SELECTOR);
8810
8831
  function getFocusableElements(element) {
@@ -8833,86 +8854,158 @@ function getFirstAndLastFocusable(parentElement) {
8833
8854
  return {};
8834
8855
  }
8835
8856
 
8857
+ /**
8858
+ * Shared listener tower for focus traps.
8859
+ *
8860
+ * When multiple traps are activated, only the last registered one is active. When it tears down, the previously
8861
+ * registered trap is re-enabled.
8862
+ */
8836
8863
  const FOCUS_TRAPS = makeListenerTowerContext();
8837
-
8838
8864
  /**
8839
8865
  * Trap 'Tab' focus switch inside the `focusZoneElement`.
8840
8866
  *
8841
- * If multiple focus trap are activated, only the last one is maintained and when a focus trap closes, the previous one
8842
- * gets activated again.
8867
+ * Setup behavior:
8868
+ * 1. Focus `focusElement` if provided and contained in the zone.
8869
+ * 2. Otherwise focus the first focusable descendant.
8870
+ * 3. Otherwise focus the zone element itself (falling back to setting `tabindex="-1"` if needed) so that
8871
+ * keyboard users (especially screen reader users) land inside the trapped region (e.g. an empty dialog).
8843
8872
  *
8844
- * @param focusZoneElement The element in which to trap the focus.
8845
- * @param focusElement The element to focus when the focus trap is activated (otherwise the first focusable element
8846
- * will be focused).
8873
+ * Tab key behavior:
8874
+ * - With at least one focusable descendant: focus cycles between the first and last focusable in the zone.
8875
+ * - With no focusable descendant: Tab is swallowed and focus is restored to the zone element itself.
8876
+ *
8877
+ * Multiple traps stack — only the latest one is active; previous traps re-enable when the latest is torn down.
8878
+ *
8879
+ * @param options Trap configuration.
8880
+ * @param signal AbortSignal used to tear down the trap.
8847
8881
  */
8848
- function useFocusTrap(focusZoneElement, focusElement) {
8849
- useEffect(() => {
8850
- // Body element can be undefined in SSR context.
8851
- const rootElement = DOCUMENT?.body;
8852
- if (!rootElement || !focusZoneElement) {
8853
- return undefined;
8882
+ function setupFocusTrap(options, signal) {
8883
+ const {
8884
+ focusZoneElement,
8885
+ focusElement
8886
+ } = options;
8887
+
8888
+ // Body element can be undefined in SSR context.
8889
+ const rootElement = DOCUMENT?.body;
8890
+ if (!rootElement || !focusZoneElement || signal.aborted) {
8891
+ return;
8892
+ }
8893
+
8894
+ // Use the shadow root as focus zone when available.
8895
+ const focusZoneElementOrShadowRoot = focusZoneElement.shadowRoot || focusZoneElement;
8896
+
8897
+ // Track whether we added a `tabindex="-1"` so we can restore the original state on teardown.
8898
+ let addedTabIndex = false;
8899
+
8900
+ /** Make the zone element programmatically focusable (so we can fall back to it). */
8901
+ const ensureZoneIsFocusable = () => {
8902
+ if (!focusZoneElement.hasAttribute('tabindex')) {
8903
+ focusZoneElement.setAttribute('tabindex', '-1');
8904
+ addedTabIndex = true;
8854
8905
  }
8906
+ };
8855
8907
 
8856
- // Use the shadow root as focusZoneElement when available
8857
- const focusZoneElementOrShadowRoot = focusZoneElement.shadowRoot || focusZoneElement;
8908
+ /** Focus the zone element itself as a last-resort fallback. */
8909
+ const focusZoneFallback = () => {
8910
+ ensureZoneIsFocusable();
8911
+ focusZoneElement.focus({
8912
+ preventScroll: true
8913
+ });
8914
+ };
8858
8915
 
8859
- // Trap 'Tab' key down focus switch into the focus zone.
8860
- const trapTabFocusInFocusZone = evt => {
8861
- const {
8862
- key
8863
- } = evt;
8864
- if (key !== 'Tab') {
8865
- return;
8866
- }
8867
- const focusable = getFirstAndLastFocusable(focusZoneElementOrShadowRoot);
8916
+ // Trap 'Tab' key down focus switch into the focus zone.
8917
+ const trapTabFocusInFocusZone = evt => {
8918
+ if (evt.key !== 'Tab') {
8919
+ return;
8920
+ }
8921
+ const focusable = getFirstAndLastFocusable(focusZoneElementOrShadowRoot);
8868
8922
 
8869
- // Prevent focus switch if no focusable available.
8870
- if (!focusable.first) {
8871
- evt.preventDefault();
8872
- return;
8873
- }
8874
- const activeElement = focusZoneElement.shadowRoot ? focusZoneElement.shadowRoot.activeElement : document.activeElement;
8875
- if (
8876
- // No previous focus
8877
- !activeElement ||
8878
- // Previous focus is at the end of the focus zone.
8879
- !evt.shiftKey && activeElement === focusable.last ||
8880
- // Previous focus is outside the focus zone
8881
- !focusZoneElementOrShadowRoot.contains(activeElement)) {
8882
- focusable.first.focus();
8883
- evt.preventDefault();
8884
- return;
8885
- }
8886
- if (
8887
- // Focus order reversed
8888
- evt.shiftKey &&
8889
- // Previous focus is at the start of the focus zone.
8890
- activeElement === focusable.first) {
8891
- focusable.last.focus();
8892
- evt.preventDefault();
8893
- }
8894
- };
8895
- const focusTrap = {
8896
- enable: () => rootElement.addEventListener('keydown', trapTabFocusInFocusZone),
8897
- disable: () => rootElement.removeEventListener('keydown', trapTabFocusInFocusZone)
8898
- };
8923
+ // Prevent focus switch if no focusable available — pin focus on the zone itself.
8924
+ if (!focusable.first) {
8925
+ evt.preventDefault();
8926
+ focusZoneFallback();
8927
+ return;
8928
+ }
8929
+ const activeElement = focusZoneElement.shadowRoot ? focusZoneElement.shadowRoot.activeElement : DOCUMENT?.activeElement;
8930
+ if (
8931
+ // No previous focus.
8932
+ !activeElement ||
8933
+ // Previous focus is at the end of the focus zone.
8934
+ !evt.shiftKey && activeElement === focusable.last ||
8935
+ // Previous focus is outside the focus zone.
8936
+ !focusZoneElementOrShadowRoot.contains(activeElement)) {
8937
+ focusable.first.focus();
8938
+ evt.preventDefault();
8939
+ return;
8940
+ }
8941
+ if (
8942
+ // Focus order reversed.
8943
+ evt.shiftKey &&
8944
+ // Previous focus is at the start of the focus zone.
8945
+ activeElement === focusable.first) {
8946
+ focusable.last.focus();
8947
+ evt.preventDefault();
8948
+ }
8949
+ };
8950
+ const focusTrap = {
8951
+ enable: () => rootElement.addEventListener('keydown', trapTabFocusInFocusZone),
8952
+ disable: () => rootElement.removeEventListener('keydown', trapTabFocusInFocusZone)
8953
+ };
8899
8954
 
8900
- // SETUP:
8901
- if (focusElement && focusZoneElementOrShadowRoot.contains(focusElement)) {
8902
- // Focus the given element.
8903
- focusElement.focus({
8955
+ // SETUP: focus initial element.
8956
+ if (focusElement && focusZoneElementOrShadowRoot.contains(focusElement)) {
8957
+ // Focus the given element.
8958
+ focusElement.focus({
8959
+ preventScroll: true
8960
+ });
8961
+ } else {
8962
+ const firstFocusable = getFirstAndLastFocusable(focusZoneElementOrShadowRoot).first;
8963
+ if (firstFocusable) {
8964
+ // Focus the first focusable descendant.
8965
+ firstFocusable.focus({
8904
8966
  preventScroll: true
8905
8967
  });
8906
8968
  } else {
8907
- // Focus the first focusable element in the zone.
8908
- getFirstAndLastFocusable(focusZoneElementOrShadowRoot).first?.focus({
8909
- preventScroll: true
8910
- });
8969
+ // No focusable descendant fall back to the zone itself (e.g. an empty dialog).
8970
+ focusZoneFallback();
8911
8971
  }
8912
- FOCUS_TRAPS.register(focusTrap);
8972
+ }
8973
+ FOCUS_TRAPS.register(focusTrap);
8913
8974
 
8914
- // TEARDOWN:
8915
- return () => FOCUS_TRAPS.unregister(focusTrap);
8975
+ // TEARDOWN.
8976
+ signal.addEventListener('abort', () => {
8977
+ FOCUS_TRAPS.unregister(focusTrap);
8978
+ if (addedTabIndex) {
8979
+ focusZoneElement.removeAttribute('tabindex');
8980
+ }
8981
+ }, {
8982
+ once: true
8983
+ });
8984
+ }
8985
+
8986
+ /**
8987
+ * Trap 'Tab' focus switch inside the `focusZoneElement`.
8988
+ *
8989
+ * If multiple focus traps are activated, only the last one is maintained and when a focus trap closes, the previous one
8990
+ * gets activated again.
8991
+ *
8992
+ * If the zone has no focusable descendant, the zone element itself receives focus (with a fallback `tabindex="-1"`).
8993
+ *
8994
+ * @param focusZoneElement The element in which to trap the focus.
8995
+ * @param focusElement The element to focus when the focus trap is activated (otherwise the first focusable element
8996
+ * will be focused — and finally the zone element itself if no focusable is found).
8997
+ */
8998
+ function useFocusTrap(focusZoneElement, focusElement) {
8999
+ useEffect(() => {
9000
+ if (!focusZoneElement) {
9001
+ return undefined;
9002
+ }
9003
+ const controller = new AbortController();
9004
+ setupFocusTrap({
9005
+ focusZoneElement,
9006
+ focusElement
9007
+ }, controller.signal);
9008
+ return () => controller.abort();
8916
9009
  }, [focusElement, focusZoneElement]);
8917
9010
  }
8918
9011
 
@@ -9453,7 +9546,7 @@ _InnerPopover.displayName = COMPONENT_NAME$15;
9453
9546
  */
9454
9547
  const Popover = skipRender(
9455
9548
  // Skip render in SSR
9456
- () => Boolean(DOCUMENT), _InnerPopover);
9549
+ () => Boolean(DOCUMENT$1), _InnerPopover);
9457
9550
  Popover.displayName = COMPONENT_NAME$15;
9458
9551
  Popover.className = CLASSNAME$14;
9459
9552
  Popover.defaultProps = DEFAULT_PROPS$V;
@@ -11738,7 +11831,7 @@ const DEFAULT_PROPS$P = {
11738
11831
  * @return React element.
11739
11832
  */
11740
11833
  const Dialog = forwardRef((props, ref) => {
11741
- if (!DOCUMENT) {
11834
+ if (!DOCUMENT$1) {
11742
11835
  // Can't render in SSR.
11743
11836
  return null;
11744
11837
  }
@@ -14147,7 +14240,7 @@ const Lightbox = forwardRef((props, ref) => {
14147
14240
  zIndex,
14148
14241
  ...forwardedProps
14149
14242
  } = props;
14150
- if (!DOCUMENT) {
14243
+ if (!DOCUMENT$1) {
14151
14244
  // Can't render in SSR.
14152
14245
  return null;
14153
14246
  }
@@ -15106,7 +15199,7 @@ const Notification = forwardRef((props, ref) => {
15106
15199
  style,
15107
15200
  ...forwardedProps
15108
15201
  } = props;
15109
- if (!DOCUMENT) {
15202
+ if (!DOCUMENT$1) {
15110
15203
  // Can't render in SSR.
15111
15204
  return null;
15112
15205
  }