@lumx/react 4.12.1-next.0 → 4.12.1-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +185 -93
  2. package/index.js.map +1 -1
  3. package/package.json +3 -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
  });
@@ -8804,8 +8818,14 @@ const ComboboxOptionMoreInfo$1 = (props, {
8804
8818
  /** CSS selector listing all tabbable elements. */
8805
8819
  const TABBABLE_ELEMENTS_SELECTOR = 'a[href], button, textarea, input:not([type="hidden"]):not([hidden]), [tabindex]';
8806
8820
 
8807
- /** CSS selector matching element that are disabled (should not receive focus). */
8808
- 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"])';
8809
8829
 
8810
8830
  const isNotDisabled = element => !element.matches(DISABLED_SELECTOR);
8811
8831
  function getFocusableElements(element) {
@@ -8834,86 +8854,158 @@ function getFirstAndLastFocusable(parentElement) {
8834
8854
  return {};
8835
8855
  }
8836
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
+ */
8837
8863
  const FOCUS_TRAPS = makeListenerTowerContext();
8838
-
8839
8864
  /**
8840
8865
  * Trap 'Tab' focus switch inside the `focusZoneElement`.
8841
8866
  *
8842
- * If multiple focus trap are activated, only the last one is maintained and when a focus trap closes, the previous one
8843
- * 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).
8844
8872
  *
8845
- * @param focusZoneElement The element in which to trap the focus.
8846
- * @param focusElement The element to focus when the focus trap is activated (otherwise the first focusable element
8847
- * 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.
8848
8881
  */
8849
- function useFocusTrap(focusZoneElement, focusElement) {
8850
- useEffect(() => {
8851
- // Body element can be undefined in SSR context.
8852
- const rootElement = DOCUMENT?.body;
8853
- if (!rootElement || !focusZoneElement) {
8854
- 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;
8855
8905
  }
8906
+ };
8856
8907
 
8857
- // Use the shadow root as focusZoneElement when available
8858
- 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
+ };
8859
8915
 
8860
- // Trap 'Tab' key down focus switch into the focus zone.
8861
- const trapTabFocusInFocusZone = evt => {
8862
- const {
8863
- key
8864
- } = evt;
8865
- if (key !== 'Tab') {
8866
- return;
8867
- }
8868
- 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);
8869
8922
 
8870
- // Prevent focus switch if no focusable available.
8871
- if (!focusable.first) {
8872
- evt.preventDefault();
8873
- return;
8874
- }
8875
- const activeElement = focusZoneElement.shadowRoot ? focusZoneElement.shadowRoot.activeElement : document.activeElement;
8876
- if (
8877
- // No previous focus
8878
- !activeElement ||
8879
- // Previous focus is at the end of the focus zone.
8880
- !evt.shiftKey && activeElement === focusable.last ||
8881
- // Previous focus is outside the focus zone
8882
- !focusZoneElementOrShadowRoot.contains(activeElement)) {
8883
- focusable.first.focus();
8884
- evt.preventDefault();
8885
- return;
8886
- }
8887
- if (
8888
- // Focus order reversed
8889
- evt.shiftKey &&
8890
- // Previous focus is at the start of the focus zone.
8891
- activeElement === focusable.first) {
8892
- focusable.last.focus();
8893
- evt.preventDefault();
8894
- }
8895
- };
8896
- const focusTrap = {
8897
- enable: () => rootElement.addEventListener('keydown', trapTabFocusInFocusZone),
8898
- disable: () => rootElement.removeEventListener('keydown', trapTabFocusInFocusZone)
8899
- };
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
+ };
8900
8954
 
8901
- // SETUP:
8902
- if (focusElement && focusZoneElementOrShadowRoot.contains(focusElement)) {
8903
- // Focus the given element.
8904
- 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({
8905
8966
  preventScroll: true
8906
8967
  });
8907
8968
  } else {
8908
- // Focus the first focusable element in the zone.
8909
- getFirstAndLastFocusable(focusZoneElementOrShadowRoot).first?.focus({
8910
- preventScroll: true
8911
- });
8969
+ // No focusable descendant fall back to the zone itself (e.g. an empty dialog).
8970
+ focusZoneFallback();
8912
8971
  }
8913
- FOCUS_TRAPS.register(focusTrap);
8972
+ }
8973
+ FOCUS_TRAPS.register(focusTrap);
8974
+
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
+ }
8914
8985
 
8915
- // TEARDOWN:
8916
- return () => FOCUS_TRAPS.unregister(focusTrap);
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();
8917
9009
  }, [focusElement, focusZoneElement]);
8918
9010
  }
8919
9011
 
@@ -9454,7 +9546,7 @@ _InnerPopover.displayName = COMPONENT_NAME$15;
9454
9546
  */
9455
9547
  const Popover = skipRender(
9456
9548
  // Skip render in SSR
9457
- () => Boolean(DOCUMENT), _InnerPopover);
9549
+ () => Boolean(DOCUMENT$1), _InnerPopover);
9458
9550
  Popover.displayName = COMPONENT_NAME$15;
9459
9551
  Popover.className = CLASSNAME$14;
9460
9552
  Popover.defaultProps = DEFAULT_PROPS$V;
@@ -11739,7 +11831,7 @@ const DEFAULT_PROPS$P = {
11739
11831
  * @return React element.
11740
11832
  */
11741
11833
  const Dialog = forwardRef((props, ref) => {
11742
- if (!DOCUMENT) {
11834
+ if (!DOCUMENT$1) {
11743
11835
  // Can't render in SSR.
11744
11836
  return null;
11745
11837
  }
@@ -14148,7 +14240,7 @@ const Lightbox = forwardRef((props, ref) => {
14148
14240
  zIndex,
14149
14241
  ...forwardedProps
14150
14242
  } = props;
14151
- if (!DOCUMENT) {
14243
+ if (!DOCUMENT$1) {
14152
14244
  // Can't render in SSR.
14153
14245
  return null;
14154
14246
  }
@@ -15107,7 +15199,7 @@ const Notification = forwardRef((props, ref) => {
15107
15199
  style,
15108
15200
  ...forwardedProps
15109
15201
  } = props;
15110
- if (!DOCUMENT) {
15202
+ if (!DOCUMENT$1) {
15111
15203
  // Can't render in SSR.
15112
15204
  return null;
15113
15205
  }