@lumx/react 4.24.0-next.3 → 4.24.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.
package/index.d.ts CHANGED
@@ -2487,13 +2487,18 @@ interface DialogProps extends GenericProps$1, HasCloseMode$1, BaseDialogProps {
2487
2487
  size?: DialogSizes;
2488
2488
  /** Z-axis position. */
2489
2489
  zIndex?: number;
2490
- /** Z-axis position. */
2490
+ /**
2491
+ * Additional props for the dialog container element.
2492
+ * Set `'aria-modal': false` to make the dialog non-modal: the page stays usable (no focus trap, no overlay,
2493
+ * no close on click away, rendered in place instead of in a portal) and escape only closes the dialog when the
2494
+ * focus is inside.
2495
+ */
2491
2496
  dialogProps?: GenericProps$1;
2492
2497
  /** On close callback. */
2493
2498
  onClose?(): void;
2494
2499
  /** Callback called when the open animation starts and the close animation finishes. */
2495
2500
  onVisibilityChange?(isVisible: boolean): void;
2496
- /** whether to disable the scroll on the body or not */
2501
+ /** Whether to disable the scroll on the body or not (ignored on a non-modal dialog: the page stays usable). */
2497
2502
  disableBodyScroll?: boolean;
2498
2503
  /** Children */
2499
2504
  children?: React__default.ReactNode;
package/index.js CHANGED
@@ -9260,6 +9260,64 @@ function getFirstAndLastFocusable(parentElement) {
9260
9260
  return {};
9261
9261
  }
9262
9262
 
9263
+ /**
9264
+ * Focus the zone element itself (last-resort fallback, e.g. an empty dialog).
9265
+ *
9266
+ * Adds a `tabindex="-1"` if needed to make the zone programmatically focusable, removed when the signal aborts.
9267
+ *
9268
+ * @param focusZoneElement The zone element.
9269
+ * @param signal AbortSignal used to remove the added `tabindex`.
9270
+ */
9271
+ function focusZoneFallback(focusZoneElement, signal) {
9272
+ if (!focusZoneElement.hasAttribute('tabindex')) {
9273
+ focusZoneElement.setAttribute('tabindex', '-1');
9274
+ signal.addEventListener('abort', () => focusZoneElement.removeAttribute('tabindex'), {
9275
+ once: true
9276
+ });
9277
+ }
9278
+ focusZoneElement.focus({
9279
+ preventScroll: true
9280
+ });
9281
+ }
9282
+
9283
+ /**
9284
+ * Move the focus into the `focusZoneElement`:
9285
+ * 1. Focus `focusElement` if provided and contained in the zone.
9286
+ * 2. Otherwise focus the first focusable descendant.
9287
+ * 3. Otherwise focus the zone element itself (falling back to setting `tabindex="-1"` if needed) so that
9288
+ * keyboard users (especially screen reader users) land inside the zone (e.g. an empty dialog).
9289
+ *
9290
+ * @param options Initial focus configuration.
9291
+ * @param signal AbortSignal used to tear down (removes the fallback `tabindex` if it was added).
9292
+ */
9293
+ function setupInitialFocus(options, signal) {
9294
+ const {
9295
+ focusZoneElement,
9296
+ focusElement
9297
+ } = options;
9298
+ if (!focusZoneElement || signal.aborted) {
9299
+ return;
9300
+ }
9301
+ if (focusElement && focusZoneElement.contains(focusElement)) {
9302
+ // Focus the given element.
9303
+ focusElement.focus({
9304
+ preventScroll: true
9305
+ });
9306
+ return;
9307
+ }
9308
+ const firstFocusable = getFirstAndLastFocusable(focusZoneElement).first;
9309
+ if (firstFocusable) {
9310
+ // Focus the first focusable descendant.
9311
+ firstFocusable.focus({
9312
+ preventScroll: true
9313
+ });
9314
+ return;
9315
+ }
9316
+
9317
+ // No focusable descendant — fall back to the zone itself.
9318
+ focusZoneFallback(focusZoneElement, signal);
9319
+ }
9320
+
9263
9321
  /**
9264
9322
  * Shared listener tower for focus traps.
9265
9323
  *
@@ -9267,14 +9325,13 @@ function getFirstAndLastFocusable(parentElement) {
9267
9325
  * registered trap is re-enabled.
9268
9326
  */
9269
9327
  const FOCUS_TRAPS = makeListenerTowerContext();
9328
+
9329
+ /** Focus trap options: the zone in which to trap the focus, and the element to focus on activation. */
9330
+
9270
9331
  /**
9271
9332
  * Trap 'Tab' focus switch inside the `focusZoneElement`.
9272
9333
  *
9273
- * Setup behavior:
9274
- * 1. Focus `focusElement` if provided and contained in the zone.
9275
- * 2. Otherwise focus the first focusable descendant.
9276
- * 3. Otherwise focus the zone element itself (falling back to setting `tabindex="-1"` if needed) so that
9277
- * keyboard users (especially screen reader users) land inside the trapped region (e.g. an empty dialog).
9334
+ * Setup behavior: move the focus into the zone (see `setupInitialFocus`).
9278
9335
  *
9279
9336
  * Tab key behavior:
9280
9337
  * - With at least one focusable descendant: focus cycles between the first and last focusable in the zone.
@@ -9287,8 +9344,7 @@ const FOCUS_TRAPS = makeListenerTowerContext();
9287
9344
  */
9288
9345
  function setupFocusTrap(options, signal) {
9289
9346
  const {
9290
- focusZoneElement,
9291
- focusElement
9347
+ focusZoneElement
9292
9348
  } = options;
9293
9349
  if (!focusZoneElement || signal.aborted) {
9294
9350
  return;
@@ -9297,25 +9353,6 @@ function setupFocusTrap(options, signal) {
9297
9353
  // The root node is either the Document (regular DOM) or a ShadowRoot (shadow DOM portal).
9298
9354
  const rootNode = focusZoneElement.getRootNode();
9299
9355
 
9300
- // Track whether we added a `tabindex="-1"` so we can restore the original state on teardown.
9301
- let addedTabIndex = false;
9302
-
9303
- /** Make the zone element programmatically focusable (so we can fall back to it). */
9304
- const ensureZoneIsFocusable = () => {
9305
- if (!focusZoneElement.hasAttribute('tabindex')) {
9306
- focusZoneElement.setAttribute('tabindex', '-1');
9307
- addedTabIndex = true;
9308
- }
9309
- };
9310
-
9311
- /** Focus the zone element itself as a last-resort fallback. */
9312
- const focusZoneFallback = () => {
9313
- ensureZoneIsFocusable();
9314
- focusZoneElement.focus({
9315
- preventScroll: true
9316
- });
9317
- };
9318
-
9319
9356
  // Trap 'Tab' key down focus switch into the focus zone.
9320
9357
  const trapTabFocusInFocusZone = evt => {
9321
9358
  if (evt.key !== 'Tab') {
@@ -9326,7 +9363,7 @@ function setupFocusTrap(options, signal) {
9326
9363
  // Prevent focus switch if no focusable available — pin focus on the zone itself.
9327
9364
  if (!focusable.first) {
9328
9365
  evt.preventDefault();
9329
- focusZoneFallback();
9366
+ focusZoneFallback(focusZoneElement, signal);
9330
9367
  return;
9331
9368
  }
9332
9369
  const {
@@ -9359,32 +9396,11 @@ function setupFocusTrap(options, signal) {
9359
9396
  };
9360
9397
 
9361
9398
  // SETUP: focus initial element.
9362
- if (focusElement && focusZoneElement.contains(focusElement)) {
9363
- // Focus the given element.
9364
- focusElement.focus({
9365
- preventScroll: true
9366
- });
9367
- } else {
9368
- const firstFocusable = getFirstAndLastFocusable(focusZoneElement).first;
9369
- if (firstFocusable) {
9370
- // Focus the first focusable descendant.
9371
- firstFocusable.focus({
9372
- preventScroll: true
9373
- });
9374
- } else {
9375
- // No focusable descendant — fall back to the zone itself (e.g. an empty dialog).
9376
- focusZoneFallback();
9377
- }
9378
- }
9399
+ setupInitialFocus(options, signal);
9379
9400
  FOCUS_TRAPS.register(focusTrap);
9380
9401
 
9381
9402
  // TEARDOWN.
9382
- signal.addEventListener('abort', () => {
9383
- FOCUS_TRAPS.unregister(focusTrap);
9384
- if (addedTabIndex) {
9385
- focusZoneElement.removeAttribute('tabindex');
9386
- }
9387
- }, {
9403
+ signal.addEventListener('abort', () => FOCUS_TRAPS.unregister(focusTrap), {
9388
9404
  once: true
9389
9405
  });
9390
9406
  }
@@ -12156,6 +12172,23 @@ const useTransitionVisibility = (ref, isComponentVisible, timeout, onVisibilityC
12156
12172
  return isVisible || isComponentVisible;
12157
12173
  };
12158
12174
 
12175
+ /**
12176
+ * Check if the focus is on the given element or inside it.
12177
+ *
12178
+ * Reads the active element of the element's own root node (Document or ShadowRoot), so it also works when the
12179
+ * element lives in a shadow DOM.
12180
+ *
12181
+ * @param element The element to check.
12182
+ * @return whether the active element is the element itself or one of its descendants.
12183
+ */
12184
+ function isFocusWithin(element) {
12185
+ if (!element) return false;
12186
+ const {
12187
+ activeElement
12188
+ } = element.getRootNode();
12189
+ return Boolean(activeElement && element.contains(activeElement));
12190
+ }
12191
+
12159
12192
  /**
12160
12193
  * Dialog label id key in IdsRegistry
12161
12194
  */
@@ -12202,6 +12235,7 @@ const DialogShell = props => {
12202
12235
  isVisible,
12203
12236
  size = DEFAULT_PROPS$U.size,
12204
12237
  zIndex,
12238
+ isModal = true,
12205
12239
  children,
12206
12240
  Portal,
12207
12241
  HeadingLevelProvider,
@@ -12210,19 +12244,21 @@ const DialogShell = props => {
12210
12244
  ...forwardedProps
12211
12245
  } = props;
12212
12246
  return /*#__PURE__*/jsx(Portal, {
12247
+ enabled: isModal,
12213
12248
  children: /*#__PURE__*/jsxs("div", {
12214
12249
  ref: ref,
12215
12250
  ...forwardedProps,
12216
12251
  className: classnames(className, block$N({
12217
12252
  'is-hidden': !isOpen,
12218
12253
  'is-loading': isLoading,
12254
+ 'is-non-modal': !isModal,
12219
12255
  'is-shown': isOpen || isVisible,
12220
12256
  [`size-${size}`]: Boolean(size)
12221
12257
  })),
12222
12258
  style: {
12223
12259
  zIndex
12224
12260
  },
12225
- children: [/*#__PURE__*/jsx("div", {
12261
+ children: [isModal && /*#__PURE__*/jsx("div", {
12226
12262
  className: element$C('overlay')
12227
12263
  }), /*#__PURE__*/jsx(HeadingLevelProvider, {
12228
12264
  level: 2,
@@ -12260,6 +12296,14 @@ function resolveAccessibleNameProps(ariaLabel, ariaLabelledBy) {
12260
12296
  * that has resolved `labelId` from the ids registry.
12261
12297
  */
12262
12298
 
12299
+ /**
12300
+ * Whether the dialog is modal: it is, unless `dialogProps['aria-modal']` is `false` (or `'false'`).
12301
+ *
12302
+ * @param dialogProps Props of the dialog container element.
12303
+ * @return whether the dialog is modal.
12304
+ */
12305
+ const isDialogModal = dialogProps => dialogProps?.['aria-modal'] !== false && dialogProps?.['aria-modal'] !== 'false';
12306
+
12263
12307
  /**
12264
12308
  * Dialog content: the `role="dialog"` element + header/body/footer.
12265
12309
  *
@@ -12301,14 +12345,17 @@ const DialogContent$1 = props => {
12301
12345
  'aria-labelledby': dialogAriaLabelledBy,
12302
12346
  ...restDialogProps
12303
12347
  } = dialogProps ?? {};
12348
+ const isModal = isDialogModal(restDialogProps);
12304
12349
  return /*#__PURE__*/jsx("div", {
12305
12350
  className: element$C('container'),
12306
12351
  role: "dialog",
12307
12352
  "aria-modal": "true",
12308
12353
  ...restDialogProps,
12309
12354
  ...resolveAccessibleNameProps(dialogAriaLabel, dialogAriaLabelledBy || labelId),
12310
- children: /*#__PURE__*/jsx(ClickAwayProvider, {
12311
- callback: !shouldPreventCloseOnClickAway && handleClose,
12355
+ children: /*#__PURE__*/jsx(ClickAwayProvider
12356
+ // A non-modal dialog leaves the page usable: clicking it must not close the dialog.
12357
+ , {
12358
+ callback: isModal && !shouldPreventCloseOnClickAway && handleClose,
12312
12359
  childrenRefs: clickAwayRefs,
12313
12360
  parentRef: rootRef,
12314
12361
  children: /*#__PURE__*/jsxs("section", {
@@ -12534,28 +12581,51 @@ const DialogBody = forwardRef((props, ref) => {
12534
12581
  preventCloseOnEscape,
12535
12582
  ...forwardedProps
12536
12583
  } = props;
12584
+ const isModal = isDialogModal(dialogProps);
12585
+ const wrapperRef = useRef(null);
12537
12586
  const previousOpen = React__default.useRef(isOpen);
12538
12587
  React__default.useEffect(() => {
12539
12588
  if (isOpen !== previousOpen.current) {
12540
12589
  previousOpen.current = isOpen;
12541
12590
 
12542
12591
  // Focus the parent element on close.
12543
- if (!isOpen && parentElement && parentElement.current) {
12592
+ // Non-modal: only when the focus is inside the dialog (do not steal the focus from the page).
12593
+ if (!isOpen && parentElement?.current && (isModal || isFocusWithin(wrapperRef.current))) {
12544
12594
  parentElement.current.focus();
12545
12595
  }
12546
12596
  }
12547
- }, [isOpen, parentElement]);
12597
+ }, [isOpen, isModal, parentElement]);
12548
12598
  const shouldPreventCloseOnEscape = preventAutoClose || preventCloseOnEscape;
12549
- useCallbackOnEscape(onClose, isOpen && !shouldPreventCloseOnEscape);
12550
- const wrapperRef = useRef(null);
12599
+
12551
12600
  /**
12552
12601
  * Since the `contentRef` comes from the parent and is optional,
12553
12602
  * we need to create a stable contentRef that will always be available.
12554
12603
  */
12555
12604
  const localContentRef = useRef(null);
12605
+
12606
+ // Close on escape (a tooltip or popover opened inside registers after the dialog and so gets the escape first).
12607
+ // Non-modal: the page stays usable, so only close when the focus is inside the dialog.
12608
+ const onEscape = React__default.useCallback(() => {
12609
+ if (isModal || isFocusWithin(wrapperRef.current)) onClose?.();
12610
+ }, [isModal, onClose]);
12611
+ useCallbackOnEscape(onClose && onEscape, isOpen && !shouldPreventCloseOnEscape);
12612
+
12556
12613
  // Handle focus trap.
12557
- useFocusTrap(isOpen && wrapperRef.current, focusElement?.current);
12558
- useDisableBodyScroll(disableBodyScroll && isOpen && localContentRef.current);
12614
+ useFocusTrap(isModal && isOpen && wrapperRef.current, focusElement?.current);
12615
+
12616
+ // Non-modal: without the focus trap, move the focus into the dialog on open ourselves.
12617
+ useEffect(() => {
12618
+ const wrapper = wrapperRef.current;
12619
+ if (isModal || !isOpen || !wrapper) return undefined;
12620
+ const controller = new AbortController();
12621
+ setupInitialFocus({
12622
+ focusZoneElement: wrapper,
12623
+ focusElement: focusElement?.current
12624
+ }, controller.signal);
12625
+ return () => controller.abort();
12626
+ // eslint-disable-next-line react-hooks/exhaustive-deps
12627
+ }, [isModal, isOpen]);
12628
+ useDisableBodyScroll(isModal && disableBodyScroll && isOpen && localContentRef.current);
12559
12629
  const [sentinelTop, setSentinelTop] = useState(null);
12560
12630
  const [sentinelBottom, setSentinelBottom] = useState(null);
12561
12631
  const intersections = useIntersectionObserver([sentinelTop, sentinelBottom], {
@@ -12587,6 +12657,7 @@ const DialogBody = forwardRef((props, ref) => {
12587
12657
  isVisible,
12588
12658
  size,
12589
12659
  zIndex,
12660
+ isModal,
12590
12661
  ref: mergeRefs(rootRef, ref),
12591
12662
  ...forwardedProps,
12592
12663
  children: /*#__PURE__*/jsx(DialogContent, {