@moneyforward/mfui-components 4.5.0 → 4.7.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 (92) hide show
  1. package/dist/src/Badge/Badge.d.ts +1 -1
  2. package/dist/src/Badge/Badge.js +4 -4
  3. package/dist/src/Badge/Badge.types.d.ts +3 -1
  4. package/dist/src/Checkbox/Checkbox.js +7 -3
  5. package/dist/src/DataTable/DataTable.d.ts +1 -1
  6. package/dist/src/DataTable/DataTable.js +23 -3
  7. package/dist/src/DataTable/DataTable.types.d.ts +2 -2
  8. package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
  9. package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
  10. package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
  11. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
  12. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
  13. package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
  14. package/dist/src/DataTable/DataTableCell/DataTableCell.js +21 -4
  15. package/dist/src/DataTable/DataTableCell/DataTableCell.types.d.ts +1 -1
  16. package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
  17. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +12 -7
  18. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
  19. package/dist/src/DataTable/DataTableProvider.d.ts +29 -2
  20. package/dist/src/DataTable/DataTableProvider.js +20 -1
  21. package/dist/src/DataTable/DataTableRow/DataTableRow.js +104 -32
  22. package/dist/src/DataTable/DataTableRow/DataTableRow.types.d.ts +44 -15
  23. package/dist/src/DataTable/DataTableRowActionContext.d.ts +34 -0
  24. package/dist/src/DataTable/DataTableRowActionContext.js +8 -0
  25. package/dist/src/DataTable/index.d.ts +1 -1
  26. package/dist/src/DateTimeSelection/DatePicker/DatePickerCalendar/DatePickerCalendar.js +7 -3
  27. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerPopover/DateRangePickerCalendar/DateRangePickerCalendar.js +1 -1
  28. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerPopover/utilities/createDateRangePickerPopoverTestUtility.js +2 -2
  29. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerProvider/DateRangePickerProvider.js +13 -7
  30. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerTrigger/DateRangePickerTrigger.js +1 -1
  31. package/dist/src/DateTimeSelection/FilterDatePicker/FilterDatePicker.js +1 -1
  32. package/dist/src/DateTimeSelection/FilterMonthPicker/FilterMonthPicker.js +1 -1
  33. package/dist/src/DateTimeSelection/FilterMonthRangePicker/FilterMonthRangePicker.js +1 -1
  34. package/dist/src/DateTimeSelection/MonthPicker/MonthPickerPanel/MonthPickerPanel.js +1 -1
  35. package/dist/src/DateTimeSelection/MonthPicker/constants.d.ts +2 -2
  36. package/dist/src/DateTimeSelection/MonthPicker/constants.js +6 -6
  37. package/dist/src/DateTimeSelection/MonthRangePicker/MonthRangePickerMonthCell/MonthRangePickerMonthCell.js +1 -1
  38. package/dist/src/DateTimeSelection/MonthRangePicker/MonthRangePickerNavigation/MonthRangePickerNavigation.js +2 -2
  39. package/dist/src/DateTimeSelection/TimePicker/TimePicker.js +1 -1
  40. package/dist/src/DateTimeSelection/shared/BasePicker/constants.d.ts +2 -2
  41. package/dist/src/DateTimeSelection/shared/BasePicker/constants.js +10 -10
  42. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerPopover/BaseRangePickerCalendar/BaseRangePickerCalendar.js +1 -1
  43. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerPopover/constants.js +4 -4
  44. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.js +13 -7
  45. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/BaseRangePickerTrigger.js +2 -2
  46. package/dist/src/DateTimeSelection/shared/CalendarGrid/CalendarGrid.js +1 -1
  47. package/dist/src/DateTimeSelection/shared/utilities/japaneseCalendar.js +2 -2
  48. package/dist/src/FileDropZone/FileDropZone.js +1 -1
  49. package/dist/src/FileDropZone/utils/fileHelpers.js +1 -1
  50. package/dist/src/MainNavigation/BaseMainNavigation.js +1 -1
  51. package/dist/src/MainNavigation/NarrowViewportMainNavigation.js +1 -1
  52. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +12 -3
  53. package/dist/src/MultipleSelectBox/MultipleSelectBoxTrigger/MultipleSelectBoxTrigger.js +1 -1
  54. package/dist/src/MultipleSelectBox/hooks/useCreateOption.js +8 -4
  55. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
  56. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +24 -12
  57. package/dist/src/MultipleSelectBox/hooks/useSelectedValues.js +16 -50
  58. package/dist/src/NumberBox/NumberBox.js +8 -4
  59. package/dist/src/PageHeader/PageHeader.types.d.ts +2 -0
  60. package/dist/src/Popover/Popover.js +11 -2
  61. package/dist/src/SelectBox/SelectBox.js +21 -13
  62. package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
  63. package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -8
  64. package/dist/src/SplitView/hooks/useSplitViewPanelVisibility.js +5 -2
  65. package/dist/src/SubNavigation/SubNavigation.js +80 -19
  66. package/dist/src/SubNavigation/SubNavigation.types.d.ts +18 -6
  67. package/dist/src/Tabs/Tabs.js +8 -4
  68. package/dist/src/Tabs/hooks/useCalculatedSelectedTabId.js +7 -5
  69. package/dist/src/Toast/ToastContainer/ToastContainer.js +11 -2
  70. package/dist/src/Tooltip/Tooltip.d.ts +6 -0
  71. package/dist/src/Tooltip/Tooltip.js +14 -3
  72. package/dist/src/Tooltip/hooks/useTooltipDisplayController.js +77 -88
  73. package/dist/src/utilities/dom/getFocusableNodes.js +1 -3
  74. package/dist/src/utilities/dom/useFixedColumns.js +6 -8
  75. package/dist/src/utilities/react/flattenChildren.d.ts +15 -0
  76. package/dist/src/utilities/react/flattenChildren.js +28 -0
  77. package/dist/src/utilities/state/useLatest.d.ts +1 -1
  78. package/dist/src/utilities/state/useLatest.js +8 -2
  79. package/dist/styled-system/recipes/badge-slot-recipe.d.ts +1 -1
  80. package/dist/styled-system/recipes/badge-slot-recipe.js +4 -0
  81. package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
  82. package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
  83. package/dist/styled-system/recipes/data-table-cell-slot-recipe.d.ts +1 -1
  84. package/dist/styled-system/recipes/data-table-cell-slot-recipe.js +4 -0
  85. package/dist/styled-system/recipes/data-table-row-slot-recipe.js +13 -6
  86. package/dist/styled-system/recipes/sub-navigation-slot-recipe.d.ts +1 -1
  87. package/dist/styled-system/recipes/sub-navigation-slot-recipe.js +12 -0
  88. package/dist/styled-system/tokens/index.js +4 -0
  89. package/dist/styled-system/tokens/tokens.d.ts +1 -1
  90. package/dist/styles.css +164 -39
  91. package/dist/tsconfig.build.tsbuildinfo +1 -1
  92. package/package.json +12 -12
@@ -44,6 +44,8 @@ export type PageHeaderProps = {
44
44
  showBackButton?: boolean;
45
45
  /**
46
46
  * It's for controlling the back button.
47
+ *
48
+ * The default `aria-label` is `戻る`, but set it to `{destination}へ戻る` (e.g. `請求書一覧へ戻る`) so that users know where the button navigates to.
47
49
  */
48
50
  backButtonProps?: Omit<IconButtonProps, 'outlined' | 'children'>;
49
51
  /**
@@ -40,6 +40,15 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
40
40
  constrainToTriggerWidth: enableConstrainedContentWidth,
41
41
  enableCenteredLayoutOnNarrowViewport,
42
42
  });
43
+ // Mirrors `refs.floating.current` into state so `TargetDomNodeProvider` below can be
44
+ // given it directly, instead of reading a ref's `.current` during render (unsafe: a
45
+ // ref update never triggers the re-render needed to pass a fresh value to context
46
+ // consumers). `refs.setFloating` still runs so floating-ui keeps positioning it.
47
+ const [floatingElement, setFloatingElement] = useState(null);
48
+ const setFloatingRef = useCallback((node) => {
49
+ refs.setFloating(node);
50
+ setFloatingElement(node);
51
+ }, [refs]);
43
52
  // Fire onOpen / onClose *only* when the open state actually changes.
44
53
  // We keep a ref of the previous open state so a new toggleEvent while already
45
54
  // open does not retrigger `onOpen`, which previously caused unexpected focus
@@ -253,8 +262,8 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
253
262
  });
254
263
  };
255
264
  }, [isPopoverOpen, refs.reference, refs.floating]);
256
- return (_jsxs(_Fragment, { children: [renderTrigger(options), shouldRenderTheContent ? (_jsx(Portal, { targetDOMNode: targetElement, children: _jsx("div", { ref: refs.setFloating, id: contentProps.id, tabIndex: contentProps.tabIndex ?? 0, "data-mfui-content": "popover-portal-content", "data-mfui-popover-depth": nestingDepth, className: cx(classes.root, 'mfui-Popover__root', contentProps.className), style: {
265
+ return (_jsxs(_Fragment, { children: [renderTrigger(options), shouldRenderTheContent ? (_jsx(Portal, { targetDOMNode: targetElement, children: _jsx("div", { ref: setFloatingRef, id: contentProps.id, tabIndex: contentProps.tabIndex ?? 0, "data-mfui-content": "popover-portal-content", "data-mfui-popover-depth": nestingDepth, className: cx(classes.root, 'mfui-Popover__root', contentProps.className), style: {
257
266
  ...floatingStyles,
258
267
  ...(!enableAutoUnmount && !isPopoverOpen && { display: 'none' }),
259
- }, onKeyDown: handleKeyDownPopover, children: _jsx(TargetDomNodeProvider, { targetDomNode: refs.floating.current, children: _jsx(PopoverContextProvider, { children: renderContent(options) }) }) }) })) : null] }));
268
+ }, onKeyDown: handleKeyDownPopover, children: _jsx(TargetDomNodeProvider, { targetDomNode: floatingElement, children: _jsx(PopoverContextProvider, { children: renderContent(options) }) }) }) })) : null] }));
260
269
  }
@@ -33,12 +33,15 @@ const DEFAULT_SCROLL_OPTIONS = {
33
33
  };
34
34
  // How many skeleton items to render while the component is loading.
35
35
  const SKELETON_ITEM_COUNT = 4;
36
+ // The default for an omitted `options` prop. A default written inline (`options = []`) is a new
37
+ // array on every render, which invalidates every memoized value derived from the options.
38
+ const EMPTY_OPTIONS = [];
36
39
  /**
37
40
  * The general-purpose SelectBox component.
38
41
  * This component switches the variants of looks and behaviors depends on the props: size, invalid, caution, disabled.
39
42
  */
40
43
  export const SelectBox = forwardRef((props, ref) => {
41
- const { id, triggerProps, clearButtonProps, enableClearButton = false, size, options = [], defaultValue,
44
+ const { id, triggerProps, clearButtonProps, enableClearButton = false, size, options = EMPTY_OPTIONS, defaultValue,
42
45
  // eslint-disable-next-line @typescript-eslint/no-deprecated
43
46
  placeholder, emptyMessage, disabled, invalid, caution = false, required, targetDOMNode, name, onChange, onBlur, value, enableSearchOptions = false, notFoundMessage, searchBoxProps, loading = false, onSearchOptions, renderDisplayValue, renderOption, renderPopoverContent, enableAutoScrollToSelectedOption, popoverContentProps, popoverWrapperProps, onOpenStateChanged, showGroupOptionDivider, triggerWrapperProps, enableAutoUnmount = true, enableConstrainedPopoverWidth = false, enableVirtualization = false, virtualizationOptions, infiniteScroll, } = props;
44
47
  const classes = selectBoxSlotRecipe({ size, showGroupOptionDivider });
@@ -47,7 +50,7 @@ export const SelectBox = forwardRef((props, ref) => {
47
50
  const optionPanelRef = useRef(null);
48
51
  const scrollWrapperRef = useRef(null);
49
52
  const searchInputRef = useRef(null);
50
- const [localSelectedOption, setLocalSelectedOption] = useState(() => defaultValue ?? null);
53
+ const [localSelectedOption, setLocalSelectedOption] = useState(() => value ?? defaultValue ?? null);
51
54
  const { isOpen: isOptionPanelOpen, toggle: toggleOptionPanel, close: closeOptionPanel, } = useDisclosure({
52
55
  value: false,
53
56
  onToggle: onOpenStateChanged,
@@ -63,17 +66,16 @@ export const SelectBox = forwardRef((props, ref) => {
63
66
  resetSearchText();
64
67
  }
65
68
  }, [isOptionPanelOpen, resetSearchText]);
66
- const selectedOption = useMemo(() => {
67
- if (value === undefined)
68
- return null;
69
- return value;
70
- }, [value]);
71
- useEffect(() => {
72
- // Only update when value prop is explicitly provided (controlled mode)
69
+ // Adjusted during render (React's documented pattern for resyncing state to a
70
+ // changed prop) rather than in an effect, so the UI never flashes the stale value
71
+ // for one frame. Only fires when value prop is explicitly provided (controlled mode).
72
+ const [prevValue, setPrevValue] = useState(value);
73
+ if (value !== prevValue) {
74
+ setPrevValue(value);
73
75
  if (value !== undefined) {
74
- setLocalSelectedOption(selectedOption);
76
+ setLocalSelectedOption(value);
75
77
  }
76
- }, [selectedOption, value]);
78
+ }
77
79
  useUpdateEffect(() => {
78
80
  if (value === undefined) {
79
81
  setLocalSelectedOption(null);
@@ -216,7 +218,7 @@ export const SelectBox = forwardRef((props, ref) => {
216
218
  });
217
219
  // Cleanup timeout on unmount
218
220
  useEffect(() => cleanup, [cleanup]);
219
- const { tabbableOptionIndex, handleOptionFocus, createKeyDownHandler } = useKeyboardNavigation({
221
+ const { tabbableOptionIndex, handleOptionFocus, createKeyDownHandler, resetTabbableIndex } = useKeyboardNavigation({
220
222
  isOptionPanelOpen,
221
223
  loading,
222
224
  enableSearchOptions,
@@ -385,6 +387,12 @@ export const SelectBox = forwardRef((props, ref) => {
385
387
  };
386
388
  const hasSelectedValue = value !== undefined || localSelectedOption !== null;
387
389
  const showClearButton = enableClearButton && hasSelectedValue;
390
+ const handleOpenStateChanged = (open) => {
391
+ // Every opening starts from the selected option, not from the option focused last time.
392
+ if (open)
393
+ resetTabbableIndex();
394
+ toggleOptionPanel();
395
+ };
388
396
  // Use Popover's centralized blur handling instead of custom logic
389
397
  return (_jsx(Popover, { open: isOptionPanelOpen, targetDOMNode: targetDOMNode, minWidth: popoverContentProps?.minWidth, maxHeight: popoverWrapperProps?.maxHeight, allowedPlacements: popoverContentProps?.allowedPlacements, enableConstrainedContentWidth: enableConstrainedPopoverWidth, renderContent: () => (_jsx("div", { ref: optionPanelRef, className: cx(classes.optionPanel, 'mfui-SelectBox__optionPanel', popoverContentProps?.className), tabIndex: -1, onKeyDown: handleKeyDownMenu, children: renderPopoverContent ? (renderPopoverContent({ searchNode, optionsNode })) : (_jsxs(_Fragment, { children: [searchNode, optionsNode] })) })), renderTrigger: ({ setTriggerRef, togglePopover, handleTriggerKeyDown, handleTriggerBlur }) => (_jsxs("div", { ref: setTriggerRef, ...triggerWrapperProps, className: cx(classes.triggerWrapper, 'mfui-SelectBox__triggerWrapper', triggerWrapperProps?.className), children: [_jsx(FocusIndicator, { children: _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", disabled: disabled, "aria-label": triggerProps?.['aria-label'], "aria-controls": isOptionPanelOpen ? listBoxId : undefined, "aria-describedby": triggerProps?.['aria-describedby'], "aria-expanded": isOptionPanelOpen, "aria-haspopup": "listbox", "aria-invalid": invalid, "aria-required": required, className: cx(classes.trigger, 'mfui-SelectBox__trigger', triggerProps?.className), "data-placeholder": !!placeholder && !localSelectedOption, "data-selected": !!localSelectedOption, "data-mfui-has-clear-button": showClearButton, "data-mfui-caution": caution && !invalid ? true : undefined, onClick: togglePopover, onKeyDown: (event) => {
390
398
  handleTypeAhead(event.nativeEvent);
@@ -392,6 +400,6 @@ export const SelectBox = forwardRef((props, ref) => {
392
400
  }, onBlur: handleTriggerBlur, children: [_jsx("span", { "data-mfui-content": "select-box-trigger-display-value", children: renderTriggerLabel() }), _jsx(DropdownIcon, { "aria-hidden": "true", focusable: false }), _jsx("input", { ref: ref, type: "hidden", value: localSelectedOption?.value ?? '', name: name, disabled: disabled })] }) }), showClearButton ? (_jsx("div", { className: cx(classes.clearButtonWrapper, 'mfui-SelectBox__clearButtonWrapper'), children: _jsx(ClearButton, { size: size === 'small' ? 'small' : size === 'large' ? 'large' : 'default', "aria-label": clearButtonProps?.['aria-label'] ?? '値をクリアする', "data-mfui-content": "select-box-clear-button", onClick: (event) => {
393
401
  event.stopPropagation();
394
402
  handleClearValue();
395
- } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: toggleOptionPanel, onBlur: onBlur }));
403
+ } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: handleOpenStateChanged, onBlur: onBlur }));
396
404
  });
397
405
  SelectBox.displayName = 'SelectBox';
@@ -73,5 +73,6 @@ export declare function useKeyboardNavigation<T extends string | number | undefi
73
73
  tabbableOptionIndex: number;
74
74
  handleOptionFocus: (optionIndex: number) => void;
75
75
  createKeyDownHandler: (option: SelectBoxOption<T, AdditionalProps>) => KeyboardEventHandler<HTMLLIElement>;
76
+ resetTabbableIndex: () => void;
76
77
  };
77
78
  export {};
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable @typescript-eslint/no-unnecessary-condition */
3
3
  import { useState, useCallback, useEffect } from 'react';
4
4
  import { findSiblingElements } from '../../utilities/dom/findSiblingElements';
5
+ import { getDeepActiveElement } from '../../utilities/dom/getDeepActiveElement';
5
6
  import { escapeAttributeSelector } from '../utils/getFlatOptions';
6
7
  import { isOptionDisabled } from '../utils/isSelectableOption';
7
8
  const DEFAULT_SCROLL_OPTIONS = {
@@ -29,22 +30,41 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
29
30
  : -1;
30
31
  return selectedIndex !== -1 ? selectedIndex : 0;
31
32
  }, [flatOptions, localSelectedOption]);
32
- const [tabbableOptionIndex, setTabbableOptionIndex] = useState(getDefaultTabbableIndex());
33
- useEffect(() => {
34
- setTabbableOptionIndex(getDefaultTabbableIndex());
35
- }, [getDefaultTabbableIndex]);
33
+ // The focused option is stored by its value, and the tabbable index is calculated during render.
34
+ // Storing the index and resetting it whenever `flatOptions` is a different array would set state
35
+ // during render, which loops forever when `flatOptions` is a new array on every render (e.g.
36
+ // `options` is omitted). The focused option stays tabbable while it is in `flatOptions`. The
37
+ // caller forgets it with `resetTabbableIndex` whenever the option panel opens, so every opening
38
+ // starts from the selected option, or the first option when nothing is selected.
39
+ const [focusedOption, setFocusedOption] = useState(null);
40
+ const focusedOptionIndex = focusedOption
41
+ ? flatOptions.findIndex((option) => option.value === focusedOption.value)
42
+ : -1;
43
+ const tabbableOptionIndex = focusedOptionIndex !== -1 ? focusedOptionIndex : getDefaultTabbableIndex();
36
44
  const resetTabbableIndex = useCallback(() => {
37
- setTabbableOptionIndex(getDefaultTabbableIndex());
38
- }, [getDefaultTabbableIndex]);
45
+ setFocusedOption(null);
46
+ }, []);
39
47
  const handleOptionFocus = useCallback((optionIndex) => {
40
- setTabbableOptionIndex(optionIndex);
48
+ const option = flatOptions[optionIndex];
49
+ if (!option) {
50
+ setFocusedOption(null);
51
+ return;
52
+ }
53
+ // Keep the current object when the same option is focused again, so React can skip the re-render.
54
+ setFocusedOption((previous) => previous && previous.value === option.value ? previous : { value: option.value });
41
55
  // Don't do auto-scroll on focus change - let selection handle its own scrolling
42
56
  // This prevents interference between focus events (from clicks) and selection logic
43
57
  // Auto-scroll should only happen when an option is actually selected, not just focused
44
- }, []);
58
+ }, [flatOptions]);
45
59
  useEffect(() => {
46
60
  if (!isOptionPanelOpen)
47
61
  return;
62
+ // The effect also runs when the options change while the panel is open (e.g. infinite scroll
63
+ // loaded more). Don't take the focus away from an option the user has already moved to.
64
+ const activeElement = getDeepActiveElement();
65
+ if (activeElement?.getAttribute('role') === 'option' && optionPanelRef.current?.contains(activeElement)) {
66
+ return;
67
+ }
48
68
  // When loading, focus on the option panel root div to enable ESC key functionality
49
69
  if (loading) {
50
70
  optionPanelRef.current?.focus({ preventScroll: true });
@@ -214,5 +234,6 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
214
234
  tabbableOptionIndex,
215
235
  handleOptionFocus,
216
236
  createKeyDownHandler,
237
+ resetTabbableIndex,
217
238
  };
218
239
  }
@@ -19,8 +19,11 @@ export function useSplitViewPanelVisibility(isOpen, controlledPanelSlot) {
19
19
  if (wasRightPanelVisibleOnMountRef.current === null) {
20
20
  wasRightPanelVisibleOnMountRef.current = isRightPanelVisible;
21
21
  }
22
- // Initialize hasRightPanelBeenVisible based on initial render state
23
- const [hasRightPanelBeenVisible, setHasRightPanelBeenVisible] = useState(() => wasRightPanelVisibleOnMountRef.current ?? false);
22
+ // Initialize hasRightPanelBeenVisible based on initial render state. Reads
23
+ // `isRightPanelVisible` directly rather than the ref just written above — on first
24
+ // render (the only render this initial value matters for) they're the same value, and
25
+ // this avoids passing a ref's `.current` into a function call during render.
26
+ const [hasRightPanelBeenVisible, setHasRightPanelBeenVisible] = useState(isRightPanelVisible);
24
27
  // Track previous visibility state
25
28
  // Note: This ref is updated in useSplitViewAnimation hook to ensure correct timing
26
29
  const previousIsRightPanelVisibleRef = useRef(isRightPanelVisible);
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DisclosureBasicCollapsed, Lock } from '@moneyforward/mfui-icons-react';
3
- import { forwardRef } from 'react';
3
+ import { forwardRef, useId } from 'react';
4
4
  import { cx } from '../../styled-system/css';
5
5
  import { subNavigationSlotRecipe } from '../../styled-system/recipes';
6
6
  import { FocusIndicator } from '../FocusIndicator';
@@ -30,6 +30,7 @@ SubNavigation.displayName = 'SubNavigation';
30
30
  * - a disclosure button with children
31
31
  *
32
32
  * This function is splitted from the SubNavigation component to allow for recursive rendering for deep nesting.
33
+ * It is called inside loops, so it must not call hooks. Create them in the rendered components instead.
33
34
  *
34
35
  * @param navigationItem - The navigation item to render.
35
36
  *
@@ -44,57 +45,117 @@ SubNavigation.displayName = 'SubNavigation';
44
45
  * @returns The rendered navigation item.
45
46
  */
46
47
  const renderNavigationItem = (navigationItem, customLinkComponent, classes, nestLevel, renderNavigationLabel) => {
47
- const { label, href, labelIcon, statusSlot, locked, lockIconProps, children } = navigationItem;
48
- if (children === undefined) {
48
+ if (navigationItem.children === undefined) {
49
49
  return (_jsx(NavigationLink, { navigationItem: navigationItem, customLinkComponent: customLinkComponent, classes: classes, renderNavigationLabel: renderNavigationLabel }));
50
50
  }
51
- if (href) {
51
+ const nestedNavigationList = (_jsx(NestedNavigationList, { parentNavigationItem: navigationItem, customLinkComponent: customLinkComponent, classes: classes, nestLevel: nestLevel + 1, renderNavigationLabel: renderNavigationLabel }));
52
+ if (navigationItem.href) {
52
53
  // The the parent item will be rendered as a link with separated disclosure icon button.
53
- return (_jsxs(_Fragment, { children: [_jsx(NavigationLink, { navigationItem: navigationItem, customLinkComponent: customLinkComponent, classes: classes, renderNavigationLabel: renderNavigationLabel }), _jsx(NestedNavigationList, { summarySlot: _jsx("div", { "data-mfui-content": "sub-navigation-disclosure-icon", className: cx(classes.disclosureIcon, 'mfui-SubNavigation__disclosureIcon'), children: _jsx(DisclosureBasicCollapsed, { role: "img", "aria-label": label }) }), nestedNavigationItems: children, customLinkComponent: customLinkComponent, classes: classes, nestLevel: nestLevel + 1, renderNavigationLabel: renderNavigationLabel })] }));
54
+ return (_jsxs(_Fragment, { children: [_jsx(NavigationLink, { navigationItem: navigationItem, customLinkComponent: customLinkComponent, classes: classes, renderNavigationLabel: renderNavigationLabel }), nestedNavigationList] }));
54
55
  }
55
56
  // The the parent item will be rendered as a disclosure button with an icon.
56
- return (_jsx(NestedNavigationList, { summarySlot: _jsxs(_Fragment, { children: [_jsx(NavigationLabelGroup, { label, labelIcon, locked, lockIconProps, statusSlot, classes, renderNavigationLabel }), _jsx("div", { "data-mfui-content": "sub-navigation-disclosure-icon", className: cx(classes.disclosureIcon, 'mfui-SubNavigation__disclosureIcon'), children: _jsx(DisclosureBasicCollapsed, { "aria-hidden": "true" }) })] }), nestedNavigationItems: children, customLinkComponent: customLinkComponent, classes: classes, nestLevel: nestLevel + 1, renderNavigationLabel: renderNavigationLabel }));
57
+ return nestedNavigationList;
57
58
  };
58
59
  /**
59
- * This is a group of a text label, an optional icon, and a status.
60
+ * Create the IDs only when the item has a description.
61
+ * Without a description, the item keeps its accessible name from its content.
62
+ *
63
+ * @param hasDescription - Whether the item has a description.
64
+ *
65
+ * @returns The IDs, or `undefined` when the item has no description.
66
+ */
67
+ function useLabelIds(hasDescription) {
68
+ const id = useId();
69
+ if (!hasDescription)
70
+ return undefined;
71
+ return { label: `${id}-label`, status: `${id}-status`, description: `${id}-description` };
72
+ }
73
+ /**
74
+ * Get the ARIA attributes for the element that contains the NavigationLabelGroup.
75
+ *
76
+ * @param labelIds - The IDs from `useLabelIds`.
77
+ *
78
+ * @param hasStatus - Whether the status is displayed.
79
+ *
80
+ * @returns The ARIA attributes. Empty when the item has no description.
81
+ */
82
+ function getLabelAriaProps(labelIds, hasStatus) {
83
+ if (!labelIds)
84
+ return {};
85
+ return {
86
+ 'aria-labelledby': hasStatus ? `${labelIds.label} ${labelIds.status}` : labelIds.label,
87
+ 'aria-describedby': labelIds.description,
88
+ };
89
+ }
90
+ /**
91
+ * This is a group of a text label, an optional description, an optional icon, and a status.
60
92
  * This is used for all navigation links and parent items for disclosure buttons in SubNavigation.
61
93
  */
62
- function NavigationLabelGroup({ label, labelIcon, locked, lockIconProps, statusSlot, classes, renderNavigationLabel, }) {
94
+ function NavigationLabelGroup({ label, description, labelIcon, locked, lockIconProps, statusSlot, labelIds, classes, renderNavigationLabel, }) {
63
95
  // If locked=true, display <Lock /> icon in preference to any other status of the statusSlot option.
64
96
  const renderStatus = () => {
65
97
  if (locked) {
66
- return (_jsx("div", { className: cx(classes.statusSlot, 'mfui-SubNavigation__statusSlot'), children: _jsx(Lock, { role: "img", "aria-label": lockIconProps?.['aria-label'] ?? 'ロックされています' }) }));
98
+ return (_jsx("span", { id: labelIds?.status, className: cx(classes.statusSlot, 'mfui-SubNavigation__statusSlot'), children: _jsx(Lock, { role: "img", "aria-label": lockIconProps?.['aria-label'] ?? 'ロックされています' }) }));
67
99
  }
68
100
  if (statusSlot) {
69
- return _jsx("div", { className: cx(classes.statusSlot, 'mfui-SubNavigation__statusSlot'), children: statusSlot });
101
+ return (_jsx("span", { id: labelIds?.status, className: cx(classes.statusSlot, 'mfui-SubNavigation__statusSlot'), children: statusSlot }));
70
102
  }
71
103
  return null;
72
104
  };
73
- return (_jsxs(_Fragment, { children: [labelIcon ? (_jsx("div", { "aria-hidden": "true", className: cx(classes.labelIcon, 'mfui-SubNavigation__labelIcon'), children: labelIcon })) : null, renderNavigationLabel ? (renderNavigationLabel(label)) : (_jsx(Typography, { variant: "strongControlLabel", className: cx(classes.label, 'mfui-SubNavigation__label'), children: label })), renderStatus()] }));
105
+ const labelText = renderNavigationLabel ? (renderNavigationLabel(label)) : (_jsx(Typography, { variant: "strongControlLabel", className: cx(classes.label, 'mfui-SubNavigation__label'), children: label }));
106
+ return (_jsxs(_Fragment, { children: [_jsxs("span", { className: cx(classes.labelContainer, 'mfui-SubNavigation__labelContainer'), children: [labelIcon ? (_jsx("span", { "aria-hidden": "true", className: cx(classes.labelIcon, 'mfui-SubNavigation__labelIcon'), children: labelIcon })) : null, _jsxs("span", { className: cx(classes.labelContent, 'mfui-SubNavigation__labelContent'), children: [labelIds ? _jsx("span", { id: labelIds.label, children: labelText }) : labelText, description ? (_jsx(Typography, { variant: "helpMessage", id: labelIds?.description, "data-mfui-content": "sub-navigation-description", className: cx(classes.description, 'mfui-SubNavigation__description'), children: description })) : null] })] }), renderStatus()] }));
74
107
  }
75
108
  /**
76
109
  * This is a link that has NavigationLabelGroup.
77
110
  * This is used for all navigation links in SubNavigation.
78
111
  */
79
112
  function NavigationLink({ navigationItem, customLinkComponent, classes, renderNavigationLabel, }) {
80
- const { label, href, isCurrent, isExternal, labelIcon, statusSlot, locked, lockIconProps } = navigationItem;
113
+ const { label, description, href, isCurrent, isExternal, labelIcon, statusSlot, locked, lockIconProps } = navigationItem;
114
+ const labelIds = useLabelIds(!!description);
115
+ const labelAriaProps = getLabelAriaProps(labelIds, !!locked || !!statusSlot);
116
+ const labelGroup = (_jsx(NavigationLabelGroup, { label,
117
+ description,
118
+ labelIcon,
119
+ locked,
120
+ lockIconProps,
121
+ statusSlot,
122
+ labelIds,
123
+ classes,
124
+ renderNavigationLabel }));
81
125
  if (navigationItem.children === undefined && navigationItem.onClick) {
82
126
  const { onClick } = navigationItem;
83
- return (_jsx(FocusIndicator, { children: _jsx("button", { type: "button", className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, onClick: () => {
127
+ return (_jsx(FocusIndicator, { children: _jsx("button", { type: "button", className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, ...labelAriaProps, onClick: () => {
84
128
  onClick(navigationItem.href);
85
- }, children: _jsx(NavigationLabelGroup, { label, labelIcon, locked, lockIconProps, statusSlot, classes, renderNavigationLabel }) }) }));
129
+ }, children: labelGroup }) }));
86
130
  }
87
131
  if (customLinkComponent && !isExternal) {
88
132
  const Tag = customLinkComponent;
89
- return (_jsx(FocusIndicator, { children: _jsx(Tag, { href: href, className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, children: _jsx(NavigationLabelGroup, { label, labelIcon, locked, lockIconProps, statusSlot, classes, renderNavigationLabel }) }) }));
133
+ return (_jsx(FocusIndicator, { children: _jsx(Tag, { href: href, className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, ...labelAriaProps, children: labelGroup }) }));
90
134
  }
91
- return (_jsx(FocusIndicator, { children: _jsx("a", { href: href, target: isExternal ? '_blank' : undefined, className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, rel: "noreferrer", children: _jsx(NavigationLabelGroup, { label, labelIcon, locked, lockIconProps, statusSlot, classes, renderNavigationLabel }) }) }));
135
+ return (_jsx(FocusIndicator, { children: _jsx("a", { href: href, target: isExternal ? '_blank' : undefined, className: cx(classes.link, 'mfui-SubNavigation__link'), "aria-current": isCurrent ? 'page' : undefined, rel: "noreferrer", ...labelAriaProps, children: labelGroup }) }));
92
136
  }
93
137
  /**
94
138
  * This is a details element that contains a list of nested navigation links.
139
+ *
140
+ * - When the parent item has `href`, the summary is only the disclosure icon button next to the parent link.
141
+ * - Otherwise, the summary is the disclosure button with the label of the parent item.
95
142
  */
96
- function NestedNavigationList({ summarySlot, nestedNavigationItems, customLinkComponent, classes, nestLevel, renderNavigationLabel, }) {
143
+ function NestedNavigationList({ parentNavigationItem, customLinkComponent, classes, nestLevel, renderNavigationLabel, }) {
144
+ const { label, description, href, labelIcon, statusSlot, locked, lockIconProps, children } = parentNavigationItem;
145
+ // With href, the description belongs to the parent link, not to the disclosure icon button.
146
+ const labelIds = useLabelIds(!href && !!description);
147
+ const labelAriaProps = getLabelAriaProps(labelIds, !!locked || !!statusSlot);
97
148
  // Check whether all descendants have isCurrent=true by calling itself recursively.
98
- const isCurrentPathInChildren = (children) => children?.some(({ isCurrent, children }) => isCurrent || (children && isCurrentPathInChildren(children))) || false;
99
- return (_jsxs("details", { open: isCurrentPathInChildren(nestedNavigationItems), className: cx(classes.details, 'mfui-SubNavigation__details'), children: [_jsx(FocusIndicator, { children: _jsx("summary", { className: cx(classes.summary, 'mfui-SubNavigation__summary'), children: summarySlot }) }), _jsx("ul", { className: cx(classes.list, 'mfui-SubNavigation__list'), children: nestedNavigationItems.map((nestedNavigationItem, index) => (_jsx("li", { className: cx(classes.nestedListItem, 'mfui-SubNavigation__nestedListItem'), "data-mfui-nest-level": nestLevel, children: nestedNavigationItem.children === undefined ? (_jsx(NavigationLink, { navigationItem: nestedNavigationItem, customLinkComponent: customLinkComponent, classes: classes, renderNavigationLabel: renderNavigationLabel })) : (renderNavigationItem(nestedNavigationItem, customLinkComponent, classes, nestLevel + 1, renderNavigationLabel)) }, index))) })] }));
149
+ const isCurrentPathInChildren = (children) => children?.some(({ isCurrent, children }) => !!isCurrent || !!(children && isCurrentPathInChildren(children))) ??
150
+ false;
151
+ const summarySlot = href ? (_jsx("span", { "data-mfui-content": "sub-navigation-disclosure-icon", className: cx(classes.disclosureIcon, 'mfui-SubNavigation__disclosureIcon'), children: _jsx(DisclosureBasicCollapsed, { role: "img", "aria-label": label }) })) : (_jsxs(_Fragment, { children: [_jsx(NavigationLabelGroup, { label,
152
+ description,
153
+ labelIcon,
154
+ locked,
155
+ lockIconProps,
156
+ statusSlot,
157
+ labelIds,
158
+ classes,
159
+ renderNavigationLabel }), _jsx("span", { "data-mfui-content": "sub-navigation-disclosure-icon", className: cx(classes.disclosureIcon, 'mfui-SubNavigation__disclosureIcon'), children: _jsx(DisclosureBasicCollapsed, { "aria-hidden": "true" }) })] }));
160
+ return (_jsxs("details", { open: isCurrentPathInChildren(children), className: cx(classes.details, 'mfui-SubNavigation__details'), children: [_jsx(FocusIndicator, { children: _jsx("summary", { className: cx(classes.summary, 'mfui-SubNavigation__summary'), ...labelAriaProps, children: summarySlot }) }), _jsx("ul", { className: cx(classes.list, 'mfui-SubNavigation__list'), children: children.map((nestedNavigationItem, index) => (_jsx("li", { className: cx(classes.nestedListItem, 'mfui-SubNavigation__nestedListItem'), "data-mfui-nest-level": nestLevel, children: nestedNavigationItem.children === undefined ? (_jsx(NavigationLink, { navigationItem: nestedNavigationItem, customLinkComponent: customLinkComponent, classes: classes, renderNavigationLabel: renderNavigationLabel })) : (renderNavigationItem(nestedNavigationItem, customLinkComponent, classes, nestLevel, renderNavigationLabel)) }, index))) })] }));
100
161
  }
@@ -76,12 +76,12 @@ type BaseSubNavigationProps = {
76
76
  customLinkComponent?: ElementType;
77
77
  /**
78
78
  * Custom render function for each navigation item's label.
79
- * Receives the item's raw `label` string and returns a ReactNode rendered inside
80
- * the default `<Typography variant="strongControlLabel">` wrapper.
81
- * When omitted, the label string is rendered directly (default behavior).
79
+ * Receives the item's raw `label` string and returns a ReactNode that replaces
80
+ * the default `<Typography variant="strongControlLabel">` label.
81
+ * When omitted, the label string is rendered with the default label style.
82
82
  *
83
- * Use this to truncate long labels or otherwise customize how labels are displayed
84
- * without losing the built-in semantic markup.
83
+ * Use this to truncate long labels or otherwise customize how labels are displayed.
84
+ * It only affects the label, not the item's `description`.
85
85
  *
86
86
  * To show a tooltip on both hover and focus, wrap the entire link with a `Tooltip`
87
87
  * via `customLinkComponent` instead — placing a `Tooltip` inside `renderNavigationLabel`
@@ -90,7 +90,7 @@ type BaseSubNavigationProps = {
90
90
  *
91
91
  * @param label - The label string of the navigation item.
92
92
  *
93
- * @returns ReactNode - Custom content rendered inside the label wrapper.
93
+ * @returns ReactNode - Custom content rendered in place of the default label.
94
94
  *
95
95
  * @example
96
96
  * ```tsx
@@ -184,6 +184,18 @@ type BaseNavigationItem = {
184
184
  * The text label briefly describing the linked page.
185
185
  */
186
186
  label: string;
187
+ /**
188
+ * A short supplementary text displayed below the text label.
189
+ *
190
+ * Screen readers announce it as the item's description, after the label and the status.
191
+ * Only plain text is accepted because the item itself is a link or a button.
192
+ *
193
+ * @example
194
+ * ```tsx
195
+ * <SubNavigation navigationItems={[{ label: '請求書', description: '作成・送付', href: '/invoices' }]} />
196
+ * ```
197
+ */
198
+ description?: string;
187
199
  /**
188
200
  * Whether the navigation item is the current page.
189
201
  *
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useMemo, useRef, useState } from 'react';
3
+ import { useMemo, useState } from 'react';
4
4
  import { tabsSlotRecipe } from '../../styled-system/recipes';
5
5
  import { useCalculatedSelectedTabId } from './hooks/useCalculatedSelectedTabId';
6
6
  import { useTabRegistry } from './hooks/useTabRegistry';
@@ -13,7 +13,10 @@ import { cx } from '../../styled-system/css';
13
13
  function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnmount = true, children, className, style, }) {
14
14
  const classNames = tabsSlotRecipe();
15
15
  const [shouldShowMenu, setShouldShowMenu] = useState(false);
16
- const wrapperRef = useRef(null);
16
+ // State (not a ref) so `getTargetDomNode` below can be given the wrapper element
17
+ // without reading a ref's `.current` during render (unsafe: a ref update never
18
+ // triggers the re-render needed to pass a fresh value into context).
19
+ const [wrapperElement, setWrapperElement] = useState(null);
17
20
  const { tabs, registerTab, updateTab, unregisterTab } = useTabRegistry();
18
21
  const { calculatedSelectedTabId, setCalculatedSelectedTab } = useCalculatedSelectedTabId({
19
22
  selectedTabId,
@@ -26,7 +29,7 @@ function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnm
26
29
  tabs,
27
30
  shouldShowMenu,
28
31
  setShouldShowMenu,
29
- targetDOMNode: getTargetDomNode(targetDOMNode, wrapperRef.current),
32
+ targetDOMNode: getTargetDomNode(targetDOMNode, wrapperElement),
30
33
  registerTab,
31
34
  updateTab,
32
35
  unregisterTab,
@@ -38,12 +41,13 @@ function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnm
38
41
  shouldShowMenu,
39
42
  setShouldShowMenu,
40
43
  targetDOMNode,
44
+ wrapperElement,
41
45
  registerTab,
42
46
  updateTab,
43
47
  unregisterTab,
44
48
  enableAutoUnmount,
45
49
  ]);
46
- return (_jsx(TabContext.Provider, { value: contextValue, children: _jsx("div", { ref: wrapperRef, className: cx(classNames.root, 'mfui-Tabs__root', className), style: style, children: children }) }));
50
+ return (_jsx(TabContext.Provider, { value: contextValue, children: _jsx("div", { ref: setWrapperElement, className: cx(classNames.root, 'mfui-Tabs__root', className), style: style, children: children }) }));
47
51
  }
48
52
  /**
49
53
  * The Tabs component manages the tabs, tab lists, and tab panels within a tab section.
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useState } from 'react';
2
2
  /**
3
3
  * Custom hook to manage the selected tab ID based on provided options.
4
4
  * Automatically updates the selected tab ID when the `selectedTabId` prop changes.
@@ -12,11 +12,13 @@ import { useEffect, useState } from 'react';
12
12
  export function useCalculatedSelectedTabId({ selectedTabId, onSelectedTabChange, tabs, }) {
13
13
  // implement
14
14
  const [calculatedSelectedTabId, setCalculatedSelectedTabId] = useState(selectedTabId);
15
- // TODO use useUpdateEffect later, after the PR has this hook is merged
16
- // Automatically update the selected tabId if the selectedTabId prop changes
17
- useEffect(() => {
15
+ // Adjusted during render (React's documented pattern for resyncing state to a changed
16
+ // prop) rather than in an effect, so the UI never flashes the stale tab for one frame.
17
+ const [prevSelectedTabId, setPrevSelectedTabId] = useState(selectedTabId);
18
+ if (selectedTabId !== prevSelectedTabId) {
19
+ setPrevSelectedTabId(selectedTabId);
18
20
  setCalculatedSelectedTabId(selectedTabId);
19
- }, [selectedTabId]);
21
+ }
20
22
  return {
21
23
  // If still nothing, we pick the first tabId from the tab list
22
24
  calculatedSelectedTabId: calculatedSelectedTabId ?? tabs[0]?.id,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { Toast } from '../Toast';
5
5
  import { toastSlotRecipe } from '../../../styled-system/recipes';
6
6
  import { cx } from '../../../styled-system/css';
@@ -34,6 +34,15 @@ export function ToastContainer({ items, onItemClose }) {
34
34
  const [containerHeight, setContainerHeight] = useState(0);
35
35
  const [activeElement, setActiveElement] = useState(null);
36
36
  const [isMobile, setIsMobile] = useState(false);
37
+ // Mirrors `firstItemRef.current` into state so `TargetDomNodeProvider` below can be
38
+ // given it directly, instead of reading a ref's `.current` during render (unsafe: a
39
+ // ref update never triggers the re-render needed to pass a fresh value to context
40
+ // consumers).
41
+ const [firstItemElement, setFirstItemElement] = useState(null);
42
+ const setFirstItemRef = useCallback((node) => {
43
+ firstItemRef.current = node;
44
+ setFirstItemElement(node);
45
+ }, []);
37
46
  // Detect viewport width for responsive CONTAINER_GAP
38
47
  useLayoutEffect(() => {
39
48
  const checkViewportWidth = () => {
@@ -114,7 +123,7 @@ export function ToastContainer({ items, onItemClose }) {
114
123
  };
115
124
  }, [items, activeElement, onItemClose]);
116
125
  const containerGap = isMobile ? CONTAINER_GAP_MOBILE : CONTAINER_GAP_DESKTOP;
117
- return (_jsx(TargetDomNodeProvider, { targetDomNode: firstItemRef.current, children: _jsx("div", { style: { height: containerHeight === 0 ? 0 : `calc(${containerHeight.toString()}px + ${containerGap})` }, className: cx(classes.container, 'mfui-ToastContainer__container'), children: items.map(({ key, onClose, duration, ...props }, index) => (_jsx(Toast, { ref: [firstItemRef, secondItemRef][index] ?? null, ...props, style: {
126
+ return (_jsx(TargetDomNodeProvider, { targetDomNode: firstItemElement, children: _jsx("div", { style: { height: containerHeight === 0 ? 0 : `calc(${containerHeight.toString()}px + ${containerGap})` }, className: cx(classes.container, 'mfui-ToastContainer__container'), children: items.map(({ key, onClose, duration, ...props }, index) => (_jsx(Toast, { ref: [setFirstItemRef, secondItemRef][index] ?? null, ...props, style: {
118
127
  zIndex: 1000 - index,
119
128
  }, activeElement: activeElement,
120
129
  // If index is 0, set the duration to the first item. Otherwise, the duration will be `undefined`.
@@ -9,6 +9,12 @@ export declare const DEFAULT_MAX_WIDTH = "268px";
9
9
  * - `className`: Applied to the root wrapper element that contains the trigger children
10
10
  * - `contentProps.className`: Applied to the floating tooltip content element
11
11
  *
12
+ * ## Layering
13
+ *
14
+ * The tooltip content is drawn on the `--mfui-z-index-tooltip` layer (`10000`), above every other layer MFUI renders.
15
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
16
+ * See the "Layering" page in Storybook for the full layer order.
17
+ *
12
18
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
13
19
  *
14
20
  * @example
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { forwardRef, useId, useRef, isValidElement, cloneElement, useEffect, useMemo } from 'react';
3
+ import { forwardRef, useId, useRef, isValidElement, cloneElement, useEffect, useMemo, useState } from 'react';
4
4
  import { getOverflowAncestors } from '@floating-ui/react-dom';
5
5
  import { cx } from '../../styled-system/css';
6
6
  import { tooltipSlotRecipe } from '../../styled-system/recipes';
@@ -22,6 +22,12 @@ export const DEFAULT_MAX_WIDTH = '268px';
22
22
  * - `className`: Applied to the root wrapper element that contains the trigger children
23
23
  * - `contentProps.className`: Applied to the floating tooltip content element
24
24
  *
25
+ * ## Layering
26
+ *
27
+ * The tooltip content is drawn on the `--mfui-z-index-tooltip` layer (`10000`), above every other layer MFUI renders.
28
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
29
+ * See the "Layering" page in Storybook for the full layer order.
30
+ *
25
31
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
26
32
  *
27
33
  * @example
@@ -52,9 +58,14 @@ export const Tooltip = forwardRef(({ children, open, onOpenStateChanged, content
52
58
  // Allow consumers to control the content id so an external element (e.g. a form field)
53
59
  // can reference the single accessible copy of the content via its own aria-describedby.
54
60
  const tooltipId = contentProps?.id ?? generatedId;
61
+ // Mirrors `rootRef.current` into state so it can be read below without reading a
62
+ // ref's `.current` during render (unsafe: a ref update never triggers the re-render
63
+ // needed to pass a fresh value onward). `rootRef` itself is kept for the effects and
64
+ // `useOnClickOutside` call further down, which read it safely outside of render.
65
+ const [rootElement, setRootElement] = useState(null);
55
66
  // Use automatic target resolution matching Portal.tsx pattern
56
67
  // Resolution order: explicit prop → parent context → document.body fallback
57
- const targetElement = useAutomaticTargetDomNode(targetDOMNode, rootRef.current);
68
+ const targetElement = useAutomaticTargetDomNode(targetDOMNode, rootElement);
58
69
  const { open: calculatedOpen, rootAttributes, tooltipAttributes, closeImmediate, closeWithManager, } = useTooltipDisplayController({
59
70
  trigger,
60
71
  wrapperProps,
@@ -89,7 +100,7 @@ export const Tooltip = forwardRef(({ children, open, onOpenStateChanged, content
89
100
  ignoreRefs: [tooltipRef],
90
101
  disabled: !calculatedOpen || !hasClickTrigger || open !== undefined,
91
102
  });
92
- const triggerRef = mergeRefs(setTriggerReference, rootRef, ref);
103
+ const triggerRef = mergeRefs(setTriggerReference, rootRef, setRootElement, ref);
93
104
  const classes = tooltipSlotRecipe();
94
105
  const ariaDescribedBy = !hideFromScreenReader && content != null ? tooltipId : undefined;
95
106
  return (_jsxs(_Fragment, { children: [_jsx("div", { ref: triggerRef, ...wrapperProps, className: cx(classes.root, 'mfui-Tooltip__root', className), ...rootAttributes, children: isValidElement(children) ? (cloneElement(children, ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : undefined)) : (_jsx("span", { ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : undefined), children: children })) }), !calculatedOpen && !hideFromScreenReader && content != null && (_jsx(Portal, { targetDOMNode: targetElement, children: _jsx("div", { "data-mfui-tooltip-hidden": true, id: tooltipId, style: {