@lumx/react 4.8.1 → 4.9.0-next.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 (3) hide show
  1. package/index.js +346 -93
  2. package/index.js.map +1 -1
  3. package/package.json +4 -3
package/index.js CHANGED
@@ -10659,6 +10659,12 @@ Progress.displayName = COMPONENT_NAME$v;
10659
10659
  Progress.className = CLASSNAME$w;
10660
10660
  Progress.defaultProps = DEFAULT_PROPS$A;
10661
10661
 
10662
+ /**
10663
+ * SSR-safe version of `useLayoutEffect`.
10664
+ * Uses `useLayoutEffect` on the client and `useEffect` on the server to avoid React SSR warnings.
10665
+ */
10666
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
10667
+
10662
10668
  const INIT_STATE = {
10663
10669
  isLazy: true,
10664
10670
  shouldActivateOnFocus: false,
@@ -10738,7 +10744,7 @@ const useTabProviderContext = (type, originalId) => {
10738
10744
  // Current tab or tab panel id.
10739
10745
  const generatedId = useId();
10740
10746
  const id = originalId || generatedId;
10741
- useEffect(() => {
10747
+ useIsomorphicLayoutEffect(() => {
10742
10748
  // On mount: register tab or tab panel id.
10743
10749
  dispatch({
10744
10750
  type: 'register',
@@ -10838,77 +10844,341 @@ const ProgressTrackerProvider = props => {
10838
10844
  };
10839
10845
  ProgressTrackerProvider.defaultProps = DEFAULT_PROPS$z;
10840
10846
 
10841
- const useRovingTabIndex = ({
10842
- parentRef,
10843
- elementSelector,
10844
- keepTabIndex,
10845
- onElementFocus,
10846
- extraDependencies = []
10847
- }) => {
10848
- useEffect(() => {
10849
- const parent = parentRef?.current;
10850
- if (!parent) {
10851
- return undefined;
10847
+ /**
10848
+ * Internal state for tracking the active (focused) item.
10849
+ * Shared by both list and grid navigation implementations.
10850
+ */
10851
+
10852
+ /**
10853
+ * Create shared active item state with cleanup on abort.
10854
+ *
10855
+ * Callback invocation:
10856
+ * - `setActive(item)`: calls `onDeactivate(previous)` then `onActivate(item)`.
10857
+ * - `clear()`: calls `onDeactivate(current)` then `onClear()`.
10858
+ * - On `signal.abort()`: same as `clear()`.
10859
+ *
10860
+ * @param callbacks Focus state change callbacks.
10861
+ * @param signal AbortSignal for cleanup.
10862
+ * @param initialItem Optional item to silently pre-select on creation (no callbacks fired).
10863
+ */
10864
+ function createActiveItemState(callbacks, signal, initialItem) {
10865
+ let activeItem = initialItem ?? null;
10866
+ function clear() {
10867
+ if (activeItem) {
10868
+ callbacks.onDeactivate(activeItem);
10869
+ activeItem = null;
10852
10870
  }
10853
- const elements = parent.querySelectorAll(elementSelector);
10854
- const initialFocusableElement = parent?.querySelector(`${elementSelector}[tabindex="0"]`);
10855
- const handleKeyDown = index => evt => {
10856
- let newTabFocus = index;
10857
- if (!(evt.key === 'ArrowRight' || evt.key === 'ArrowLeft')) {
10858
- return;
10871
+ callbacks.onClear?.();
10872
+ }
10873
+ signal.addEventListener('abort', clear);
10874
+ return {
10875
+ get active() {
10876
+ return activeItem;
10877
+ },
10878
+ setActive(item) {
10879
+ if (activeItem === item) return;
10880
+ if (activeItem) {
10881
+ callbacks.onDeactivate(activeItem);
10859
10882
  }
10860
- if (evt.key === 'ArrowRight') {
10861
- // Move right
10862
- newTabFocus += 1;
10863
- // If we're at the end, go to the start
10864
- if (newTabFocus >= elements.length) {
10865
- newTabFocus = 0;
10866
- }
10867
- } else if (evt.key === 'ArrowLeft') {
10868
- // Move left
10869
- newTabFocus -= 1;
10870
- if (newTabFocus < 0) {
10871
- // If we're at the start, move to the end
10872
- newTabFocus = elements.length - 1;
10873
- }
10883
+ activeItem = item;
10884
+ if (item) {
10885
+ callbacks.onActivate(item);
10886
+ }
10887
+ },
10888
+ clear
10889
+ };
10890
+ }
10891
+
10892
+ /**
10893
+ * Create a focus navigation controller for a 1D list.
10894
+ *
10895
+ * @param options List navigation options (container, itemSelector, direction, wrap).
10896
+ * @param callbacks Callbacks for focus state changes.
10897
+ * @param signal AbortSignal for cleanup.
10898
+ * @returns FocusNavigationController instance.
10899
+ */
10900
+ function createListFocusNavigation(options, callbacks, signal) {
10901
+ const {
10902
+ container,
10903
+ itemSelector,
10904
+ direction = 'vertical',
10905
+ wrap = false,
10906
+ itemDisabledSelector,
10907
+ itemActiveSelector
10908
+ } = options;
10909
+ const initialItem = itemActiveSelector ? container.querySelector(itemActiveSelector) ?? null : null;
10910
+ const state = createActiveItemState(callbacks, signal, initialItem);
10911
+
10912
+ /** Combined CSS selector matching enabled (non-disabled) items. */
10913
+ const enabledItemSelector = itemDisabledSelector ? `${itemSelector}:not(${itemDisabledSelector})` : itemSelector;
10914
+
10915
+ /**
10916
+ * Create a TreeWalker over items in the container.
10917
+ * @param enabledOnly When true (default), disabled items are skipped.
10918
+ */
10919
+ function createItemWalker(enabledOnly = true) {
10920
+ const selector = enabledOnly ? enabledItemSelector : itemSelector;
10921
+ return document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
10922
+ acceptNode(node) {
10923
+ return node.matches(selector) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
10874
10924
  }
10875
- const newElement = elements[newTabFocus];
10876
- newElement?.focus();
10925
+ });
10926
+ }
10927
+
10928
+ /** Find the first enabled item in the container. */
10929
+ function findFirstEnabled() {
10930
+ return container.querySelector(enabledItemSelector);
10931
+ }
10932
+
10933
+ /** Find the last enabled item in the container. */
10934
+ function findLastEnabled() {
10935
+ const items = container.querySelectorAll(enabledItemSelector);
10936
+ return items.length > 0 ? items[items.length - 1] : null;
10937
+ }
10938
+
10939
+ /** Navigate to the first enabled item and set it as active. */
10940
+ function goToFirst() {
10941
+ const first = findFirstEnabled();
10942
+ if (!first) return false;
10943
+ state.setActive(first);
10944
+ return true;
10945
+ }
10877
10946
 
10878
- // When an element is focused using roving tab index, trigger the onElementFocus callback
10879
- if (newElement && onElementFocus) {
10880
- onElementFocus(newElement);
10947
+ /** Navigate to the last enabled item and set it as active. */
10948
+ function goToLast() {
10949
+ const last = findLastEnabled();
10950
+ if (!last) return false;
10951
+ state.setActive(last);
10952
+ return true;
10953
+ }
10954
+ function navigateByOffset(offset) {
10955
+ if (offset === 0) return state.active !== null;
10956
+ const forward = offset > 0;
10957
+ const stepsNeeded = Math.abs(offset);
10958
+
10959
+ // No active item — try to recover from DOM state, then fall back to first/last.
10960
+ if (!state.active) {
10961
+ const activeFromDom = itemActiveSelector && container.querySelector(itemActiveSelector);
10962
+ if (activeFromDom && activeFromDom.matches(enabledItemSelector)) {
10963
+ state.setActive(activeFromDom);
10964
+ } else {
10965
+ const started = forward ? goToFirst() : goToLast();
10966
+ if (!started) return false;
10881
10967
  }
10882
- if (keepTabIndex) {
10883
- evt.currentTarget.setAttribute('tabindex', '-1');
10884
- newElement?.setAttribute('tabindex', '0');
10968
+ if (stepsNeeded === 1) return true;
10969
+ return navigateByOffset(forward ? offset - 1 : offset + 1);
10970
+ }
10971
+
10972
+ // Walk from the active item using a TreeWalker.
10973
+ const walker = createItemWalker();
10974
+ walker.currentNode = state.active;
10975
+ const step = forward ? () => walker.nextNode() : () => walker.previousNode();
10976
+ let stepsCompleted = 0;
10977
+ let lastFound = null;
10978
+ for (let i = 0; i < stepsNeeded; i++) {
10979
+ const next = step();
10980
+ if (next) {
10981
+ lastFound = next;
10982
+ stepsCompleted += 1;
10983
+ } else if (wrap) {
10984
+ // Hit boundary — wrap around to the opposite end.
10985
+ const wrapped = forward ? findFirstEnabled() : findLastEnabled();
10986
+ if (!wrapped || wrapped === state.active) break;
10987
+ lastFound = wrapped;
10988
+ stepsCompleted += 1;
10989
+ walker.currentNode = wrapped;
10990
+ } else {
10991
+ break;
10885
10992
  }
10886
- };
10887
- if (elements?.length > 0) {
10888
- elements.forEach((el, key) => {
10889
- // if no element has tabindex set to 0, set the first element as focusable
10890
- if (!initialFocusableElement && key === 0) {
10891
- el.setAttribute('tabindex', '0');
10892
- // set all other to -1
10893
- } else if (initialFocusableElement !== el) {
10894
- el.setAttribute('tabindex', '-1');
10993
+ }
10994
+ if (stepsCompleted === 0) return false;
10995
+ state.setActive(lastFound);
10996
+ return true;
10997
+ }
10998
+ const navigateForward = () => navigateByOffset(1);
10999
+ const navigateBackward = () => navigateByOffset(-1);
11000
+ return {
11001
+ type: 'list',
11002
+ get activeItem() {
11003
+ return state.active;
11004
+ },
11005
+ get hasActiveItem() {
11006
+ return state.active !== null;
11007
+ },
11008
+ get hasNavigableItems() {
11009
+ return container.querySelector(enabledItemSelector) !== null;
11010
+ },
11011
+ goToFirst,
11012
+ goToLast,
11013
+ goToItem(item) {
11014
+ if (!item.matches(itemSelector)) return false;
11015
+ if (!container.contains(item)) return false;
11016
+ state.setActive(item);
11017
+ return true;
11018
+ },
11019
+ goToOffset(offset) {
11020
+ return navigateByOffset(offset);
11021
+ },
11022
+ goToItemMatching(predicate) {
11023
+ const walker = createItemWalker(false);
11024
+ let node = walker.nextNode();
11025
+ while (node) {
11026
+ if (predicate(node)) {
11027
+ state.setActive(node);
11028
+ return true;
10895
11029
  }
10896
- // add event listener
10897
- el.addEventListener('keydown', handleKeyDown(key));
10898
- });
11030
+ node = walker.nextNode();
11031
+ }
11032
+ return false;
11033
+ },
11034
+ clear: state.clear,
11035
+ goUp() {
11036
+ return direction === 'vertical' ? navigateBackward() : false;
11037
+ },
11038
+ goDown() {
11039
+ return direction === 'vertical' ? navigateForward() : false;
11040
+ },
11041
+ goLeft() {
11042
+ return direction === 'horizontal' ? navigateBackward() : false;
11043
+ },
11044
+ goRight() {
11045
+ return direction === 'horizontal' ? navigateForward() : false;
10899
11046
  }
11047
+ };
11048
+ }
10900
11049
 
10901
- // Cleanup listeners
10902
- return () => {
10903
- if (elements?.length > 0) {
10904
- elements.forEach((el, key) => {
10905
- el.removeEventListener('keydown', handleKeyDown(key));
10906
- });
11050
+ /**
11051
+ * Options for the roving tabindex setup.
11052
+ */
11053
+
11054
+ /**
11055
+ * Set up the roving tabindex pattern on a container element.
11056
+ *
11057
+ * Handles:
11058
+ * - Keyboard navigation (Arrow keys, Home, End) via a keydown listener on the container
11059
+ * - tabindex management on focus changes (`0` on active, `-1` on inactive)
11060
+ * - Calling `.focus()` on the newly active item
11061
+ *
11062
+ * The consumer is responsible for setting the initial tabindex values on items
11063
+ * (`tabindex="0"` on the active item, `tabindex="-1"` on the rest). On setup, the item
11064
+ * with `tabindex="0"` is silently adopted as the initial active item.
11065
+ *
11066
+ * The setup is torn down when the provided `signal` is aborted.
11067
+ *
11068
+ * @param options Roving tabindex configuration.
11069
+ * @param signal AbortSignal for teardown.
11070
+ * @returns The underlying {@link FocusNavigationController} for programmatic access.
11071
+ */
11072
+ function setupRovingTabIndex(options, signal) {
11073
+ const {
11074
+ container,
11075
+ itemSelector,
11076
+ direction = 'horizontal',
11077
+ itemDisabledSelector,
11078
+ onItemFocused
11079
+ } = options;
11080
+ const nav = createListFocusNavigation({
11081
+ container,
11082
+ itemSelector,
11083
+ direction,
11084
+ wrap: true,
11085
+ itemDisabledSelector,
11086
+ itemActiveSelector: `${itemSelector}[tabindex="0"]`
11087
+ }, {
11088
+ onActivate(item) {
11089
+ item.setAttribute('tabindex', '0');
11090
+ item.focus();
11091
+ onItemFocused?.(item);
11092
+ },
11093
+ onDeactivate(item) {
11094
+ item.setAttribute('tabindex', '-1');
11095
+ }
11096
+ }, signal);
11097
+ container.addEventListener('keydown', evt => {
11098
+ // Clear stale reference if the active item was removed from the DOM.
11099
+ if (nav.hasActiveItem && !nav.activeItem?.isConnected) {
11100
+ nav.clear();
11101
+ }
11102
+
11103
+ // Sync: if nothing is active yet, pick up focus from the event target.
11104
+ if (!nav.hasActiveItem) {
11105
+ const target = evt.target;
11106
+ if (target.matches(itemSelector) && container.contains(target)) {
11107
+ nav.goToItem(target);
10907
11108
  }
10908
- };
10909
- },
11109
+ }
11110
+ let handled = false;
11111
+ switch (evt.key) {
11112
+ case 'ArrowRight':
11113
+ handled = nav.goRight();
11114
+ break;
11115
+ case 'ArrowLeft':
11116
+ handled = nav.goLeft();
11117
+ break;
11118
+ case 'ArrowDown':
11119
+ handled = nav.goDown();
11120
+ break;
11121
+ case 'ArrowUp':
11122
+ handled = nav.goUp();
11123
+ break;
11124
+ case 'Home':
11125
+ handled = nav.goToFirst();
11126
+ break;
11127
+ case 'End':
11128
+ handled = nav.goToLast();
11129
+ break;
11130
+ }
11131
+ if (handled) {
11132
+ evt.preventDefault();
11133
+ evt.stopPropagation();
11134
+ }
11135
+ }, {
11136
+ signal
11137
+ });
11138
+ return nav;
11139
+ }
11140
+
11141
+ /**
11142
+ * Returns a stable callback that always calls the latest version of `fn`.
11143
+ * Useful to avoid re-running effects when a callback prop changes reference.
11144
+ *
11145
+ * https://github.com/facebook/react/issues/14099#issuecomment-440013892
11146
+ *
11147
+ * @param fn A function to stabilize.
11148
+ * @return A stable function with the same signature.
11149
+ */
11150
+ function useEventCallback(fn) {
11151
+ const ref = React__default.useRef(fn);
11152
+ useIsomorphicLayoutEffect(() => {
11153
+ ref.current = fn;
11154
+ });
10910
11155
  // eslint-disable-next-line react-hooks/exhaustive-deps
10911
- [parentRef, ...extraDependencies]);
11156
+ return React__default.useCallback((...args) => ref.current?.(...args), []);
11157
+ }
11158
+
11159
+ const useRovingTabIndexContainer = ({
11160
+ containerRef,
11161
+ itemSelector,
11162
+ onItemFocused: unstableOnItemFocused,
11163
+ direction,
11164
+ itemDisabledSelector
11165
+ }) => {
11166
+ const onItemFocused = useEventCallback(unstableOnItemFocused);
11167
+ useIsomorphicLayoutEffect(() => {
11168
+ const container = containerRef?.current;
11169
+ if (!container) {
11170
+ return undefined;
11171
+ }
11172
+ const abortController = new AbortController();
11173
+ setupRovingTabIndex({
11174
+ container,
11175
+ itemSelector,
11176
+ direction,
11177
+ itemDisabledSelector,
11178
+ onItemFocused
11179
+ }, abortController.signal);
11180
+ return () => abortController.abort();
11181
+ }, [containerRef, itemSelector, direction, itemDisabledSelector, onItemFocused]);
10912
11182
  };
10913
11183
 
10914
11184
  /**
@@ -10947,11 +11217,9 @@ const ProgressTracker = forwardRef((props, ref) => {
10947
11217
  ...forwardedProps
10948
11218
  } = props;
10949
11219
  const stepListRef = React__default.useRef(null);
10950
- useRovingTabIndex({
10951
- parentRef: stepListRef,
10952
- elementSelector: '[role="tab"]',
10953
- keepTabIndex: false,
10954
- extraDependencies: [children]
11220
+ useRovingTabIndexContainer({
11221
+ containerRef: stepListRef,
11222
+ itemSelector: '[role="tab"]'
10955
11223
  });
10956
11224
  const state = useTabProviderContextState();
10957
11225
  const numberOfSteps = state?.ids?.tab?.length || 0;
@@ -12251,22 +12519,6 @@ SkeletonTypography.displayName = COMPONENT_NAME$j;
12251
12519
  SkeletonTypography.defaultProps = DEFAULT_PROPS$n;
12252
12520
  SkeletonTypography.className = CLASSNAME$j;
12253
12521
 
12254
- const useEnhancedEffect = typeof window !== 'undefined' ? React__default.useLayoutEffect : React__default.useEffect;
12255
-
12256
- /**
12257
- * https://github.com/facebook/react/issues/14099#issuecomment-440013892
12258
- *
12259
- * @param fn A function to run
12260
- * @return A React callback
12261
- */
12262
- function useEventCallback(fn) {
12263
- const ref = React__default.useRef(fn);
12264
- useEnhancedEffect(() => {
12265
- ref.current = fn;
12266
- });
12267
- return React__default.useCallback(event => ref.current(event), []);
12268
- }
12269
-
12270
12522
  /**
12271
12523
  * Clamp value in range.
12272
12524
  *
@@ -13212,11 +13464,10 @@ const InternalSlideshowControls = forwardRef((props, ref) => {
13212
13464
  /**
13213
13465
  * Add roving tab index pattern to pagination items and activate slide on focus.
13214
13466
  */
13215
- useRovingTabIndex({
13216
- parentRef: paginationRef,
13217
- elementSelector: 'button',
13218
- keepTabIndex: true,
13219
- onElementFocus: el => {
13467
+ useRovingTabIndexContainer({
13468
+ containerRef: paginationRef,
13469
+ itemSelector: 'button',
13470
+ onItemFocused: el => {
13220
13471
  el.click();
13221
13472
  }
13222
13473
  });
@@ -14145,7 +14396,11 @@ const TabProvider = props => {
14145
14396
  onChange,
14146
14397
  ...propState
14147
14398
  } = props;
14148
- const [state, dispatch] = useReducer(reducer, INIT_STATE);
14399
+ const [state, dispatch] = useReducer(reducer, {
14400
+ ...INIT_STATE,
14401
+ ...DEFAULT_PROPS$c,
14402
+ ...propState
14403
+ });
14149
14404
 
14150
14405
  // On prop state change => dispatch update.
14151
14406
  useEffect(() => {
@@ -14236,11 +14491,9 @@ const TabList = forwardRef((props, ref) => {
14236
14491
  ...forwardedProps
14237
14492
  } = props;
14238
14493
  const tabListRef = React__default.useRef(null);
14239
- useRovingTabIndex({
14240
- parentRef: tabListRef,
14241
- elementSelector: '[role="tab"]',
14242
- keepTabIndex: false,
14243
- extraDependencies: [children]
14494
+ useRovingTabIndexContainer({
14495
+ containerRef: tabListRef,
14496
+ itemSelector: '[role="tab"]'
14244
14497
  });
14245
14498
  return /*#__PURE__*/jsx("div", {
14246
14499
  ref: mergeRefs(ref, tabListRef),