@moneyforward/mfui-components 4.5.0 → 4.6.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 (71) 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.types.d.ts +2 -2
  6. package/dist/src/DataTable/DataTableCell/DataTableCell.js +21 -4
  7. package/dist/src/DataTable/DataTableCell/DataTableCell.types.d.ts +1 -1
  8. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +7 -5
  9. package/dist/src/DataTable/DataTableRow/DataTableRow.js +104 -32
  10. package/dist/src/DataTable/DataTableRow/DataTableRow.types.d.ts +44 -15
  11. package/dist/src/DataTable/DataTableRowActionContext.d.ts +34 -0
  12. package/dist/src/DataTable/DataTableRowActionContext.js +8 -0
  13. package/dist/src/DateTimeSelection/DatePicker/DatePickerCalendar/DatePickerCalendar.js +7 -3
  14. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerPopover/DateRangePickerCalendar/DateRangePickerCalendar.js +1 -1
  15. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerPopover/utilities/createDateRangePickerPopoverTestUtility.js +2 -2
  16. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerProvider/DateRangePickerProvider.js +13 -7
  17. package/dist/src/DateTimeSelection/DateRangePicker/DateRangePickerTrigger/DateRangePickerTrigger.js +1 -1
  18. package/dist/src/DateTimeSelection/FilterDatePicker/FilterDatePicker.js +1 -1
  19. package/dist/src/DateTimeSelection/FilterMonthPicker/FilterMonthPicker.js +1 -1
  20. package/dist/src/DateTimeSelection/FilterMonthRangePicker/FilterMonthRangePicker.js +1 -1
  21. package/dist/src/DateTimeSelection/MonthPicker/MonthPickerPanel/MonthPickerPanel.js +1 -1
  22. package/dist/src/DateTimeSelection/MonthPicker/constants.d.ts +2 -2
  23. package/dist/src/DateTimeSelection/MonthPicker/constants.js +6 -6
  24. package/dist/src/DateTimeSelection/MonthRangePicker/MonthRangePickerMonthCell/MonthRangePickerMonthCell.js +1 -1
  25. package/dist/src/DateTimeSelection/MonthRangePicker/MonthRangePickerNavigation/MonthRangePickerNavigation.js +2 -2
  26. package/dist/src/DateTimeSelection/TimePicker/TimePicker.js +1 -1
  27. package/dist/src/DateTimeSelection/shared/BasePicker/constants.d.ts +2 -2
  28. package/dist/src/DateTimeSelection/shared/BasePicker/constants.js +10 -10
  29. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerPopover/BaseRangePickerCalendar/BaseRangePickerCalendar.js +1 -1
  30. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerPopover/constants.js +4 -4
  31. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.js +13 -7
  32. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/BaseRangePickerTrigger.js +2 -2
  33. package/dist/src/DateTimeSelection/shared/CalendarGrid/CalendarGrid.js +1 -1
  34. package/dist/src/DateTimeSelection/shared/utilities/japaneseCalendar.js +2 -2
  35. package/dist/src/FileDropZone/FileDropZone.js +1 -1
  36. package/dist/src/FileDropZone/utils/fileHelpers.js +1 -1
  37. package/dist/src/MainNavigation/BaseMainNavigation.js +1 -1
  38. package/dist/src/MainNavigation/NarrowViewportMainNavigation.js +1 -1
  39. package/dist/src/MultipleSelectBox/MultipleSelectBoxTrigger/MultipleSelectBoxTrigger.js +1 -1
  40. package/dist/src/MultipleSelectBox/hooks/useCreateOption.js +8 -4
  41. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +9 -4
  42. package/dist/src/MultipleSelectBox/hooks/useSelectedValues.js +16 -50
  43. package/dist/src/NumberBox/NumberBox.js +8 -4
  44. package/dist/src/PageHeader/PageHeader.types.d.ts +2 -0
  45. package/dist/src/Popover/Popover.js +11 -2
  46. package/dist/src/SelectBox/SelectBox.js +9 -10
  47. package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +8 -2
  48. package/dist/src/SplitView/hooks/useSplitViewPanelVisibility.js +5 -2
  49. package/dist/src/SubNavigation/SubNavigation.js +80 -19
  50. package/dist/src/SubNavigation/SubNavigation.types.d.ts +18 -6
  51. package/dist/src/Tabs/Tabs.js +8 -4
  52. package/dist/src/Tabs/hooks/useCalculatedSelectedTabId.js +7 -5
  53. package/dist/src/Toast/ToastContainer/ToastContainer.js +11 -2
  54. package/dist/src/Tooltip/Tooltip.js +8 -3
  55. package/dist/src/Tooltip/hooks/useTooltipDisplayController.js +77 -88
  56. package/dist/src/utilities/dom/getFocusableNodes.js +1 -3
  57. package/dist/src/utilities/dom/useFixedColumns.js +6 -8
  58. package/dist/src/utilities/react/flattenChildren.d.ts +15 -0
  59. package/dist/src/utilities/react/flattenChildren.js +28 -0
  60. package/dist/src/utilities/state/useLatest.d.ts +1 -1
  61. package/dist/src/utilities/state/useLatest.js +8 -2
  62. package/dist/styled-system/recipes/badge-slot-recipe.d.ts +1 -1
  63. package/dist/styled-system/recipes/badge-slot-recipe.js +4 -0
  64. package/dist/styled-system/recipes/data-table-cell-slot-recipe.d.ts +1 -1
  65. package/dist/styled-system/recipes/data-table-cell-slot-recipe.js +4 -0
  66. package/dist/styled-system/recipes/data-table-row-slot-recipe.js +13 -6
  67. package/dist/styled-system/recipes/sub-navigation-slot-recipe.d.ts +1 -1
  68. package/dist/styled-system/recipes/sub-navigation-slot-recipe.js +12 -0
  69. package/dist/styles.css +155 -35
  70. package/dist/tsconfig.build.tsbuildinfo +1 -1
  71. package/package.json +12 -12
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { forwardRef, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
3
+ import { forwardRef, useCallback, useLayoutEffect, useReducer, useRef, useState } from 'react';
4
4
  import { cx } from '../../styled-system/css';
5
5
  import { mergeRefs } from '../utilities/dom/mergeRefs';
6
6
  import { TextBox } from '../TextBox';
@@ -149,13 +149,17 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
149
149
  const pendingCursorRef = useRef(null);
150
150
  const [cursorRestoreToken, requestCursorRestore] = useReducer((count) => count + 1, 0);
151
151
  const [displayValue, setDisplayValue] = useState(() => formatWithCommas(sanitizeInput(String(value ?? defaultValue ?? ''))));
152
- // Sync with controlled value
153
- useEffect(() => {
152
+ // Adjusted during render (React's documented pattern for resyncing state to a
153
+ // changed prop) rather than in an effect, so the UI never flashes the stale value
154
+ // for one frame. Only fires when value prop is explicitly provided (controlled mode).
155
+ const [prevValue, setPrevValue] = useState(value);
156
+ if (value !== prevValue) {
157
+ setPrevValue(value);
154
158
  if (value !== undefined) {
155
159
  const sanitized = sanitizeInput(String(value));
156
160
  setDisplayValue(formatWithCommas(sanitized));
157
161
  }
158
- }, [value]);
162
+ }
159
163
  // Restore cursor position after display value update
160
164
  useLayoutEffect(() => {
161
165
  if (pendingCursorRef.current !== null && inputRef.current) {
@@ -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
  }
@@ -47,7 +47,7 @@ export const SelectBox = forwardRef((props, ref) => {
47
47
  const optionPanelRef = useRef(null);
48
48
  const scrollWrapperRef = useRef(null);
49
49
  const searchInputRef = useRef(null);
50
- const [localSelectedOption, setLocalSelectedOption] = useState(() => defaultValue ?? null);
50
+ const [localSelectedOption, setLocalSelectedOption] = useState(() => value ?? defaultValue ?? null);
51
51
  const { isOpen: isOptionPanelOpen, toggle: toggleOptionPanel, close: closeOptionPanel, } = useDisclosure({
52
52
  value: false,
53
53
  onToggle: onOpenStateChanged,
@@ -63,17 +63,16 @@ export const SelectBox = forwardRef((props, ref) => {
63
63
  resetSearchText();
64
64
  }
65
65
  }, [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)
66
+ // Adjusted during render (React's documented pattern for resyncing state to a
67
+ // changed prop) rather than in an effect, so the UI never flashes the stale value
68
+ // for one frame. Only fires when value prop is explicitly provided (controlled mode).
69
+ const [prevValue, setPrevValue] = useState(value);
70
+ if (value !== prevValue) {
71
+ setPrevValue(value);
73
72
  if (value !== undefined) {
74
- setLocalSelectedOption(selectedOption);
73
+ setLocalSelectedOption(value);
75
74
  }
76
- }, [selectedOption, value]);
75
+ }
77
76
  useUpdateEffect(() => {
78
77
  if (value === undefined) {
79
78
  setLocalSelectedOption(null);
@@ -30,9 +30,15 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
30
30
  return selectedIndex !== -1 ? selectedIndex : 0;
31
31
  }, [flatOptions, localSelectedOption]);
32
32
  const [tabbableOptionIndex, setTabbableOptionIndex] = useState(getDefaultTabbableIndex());
33
- useEffect(() => {
33
+ // Adjusted during render (React's documented pattern for resyncing state to changed
34
+ // props) rather than in an effect, so the UI never flashes the stale index for one frame.
35
+ const [prevFlatOptions, setPrevFlatOptions] = useState(flatOptions);
36
+ const [prevLocalSelectedOption, setPrevLocalSelectedOption] = useState(localSelectedOption);
37
+ if (flatOptions !== prevFlatOptions || localSelectedOption !== prevLocalSelectedOption) {
38
+ setPrevFlatOptions(flatOptions);
39
+ setPrevLocalSelectedOption(localSelectedOption);
34
40
  setTabbableOptionIndex(getDefaultTabbableIndex());
35
- }, [getDefaultTabbableIndex]);
41
+ }
36
42
  const resetTabbableIndex = useCallback(() => {
37
43
  setTabbableOptionIndex(getDefaultTabbableIndex());
38
44
  }, [getDefaultTabbableIndex]);
@@ -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`.
@@ -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';
@@ -52,9 +52,14 @@ export const Tooltip = forwardRef(({ children, open, onOpenStateChanged, content
52
52
  // Allow consumers to control the content id so an external element (e.g. a form field)
53
53
  // can reference the single accessible copy of the content via its own aria-describedby.
54
54
  const tooltipId = contentProps?.id ?? generatedId;
55
+ // Mirrors `rootRef.current` into state so it can be read below without reading a
56
+ // ref's `.current` during render (unsafe: a ref update never triggers the re-render
57
+ // needed to pass a fresh value onward). `rootRef` itself is kept for the effects and
58
+ // `useOnClickOutside` call further down, which read it safely outside of render.
59
+ const [rootElement, setRootElement] = useState(null);
55
60
  // Use automatic target resolution matching Portal.tsx pattern
56
61
  // Resolution order: explicit prop → parent context → document.body fallback
57
- const targetElement = useAutomaticTargetDomNode(targetDOMNode, rootRef.current);
62
+ const targetElement = useAutomaticTargetDomNode(targetDOMNode, rootElement);
58
63
  const { open: calculatedOpen, rootAttributes, tooltipAttributes, closeImmediate, closeWithManager, } = useTooltipDisplayController({
59
64
  trigger,
60
65
  wrapperProps,
@@ -89,7 +94,7 @@ export const Tooltip = forwardRef(({ children, open, onOpenStateChanged, content
89
94
  ignoreRefs: [tooltipRef],
90
95
  disabled: !calculatedOpen || !hasClickTrigger || open !== undefined,
91
96
  });
92
- const triggerRef = mergeRefs(setTriggerReference, rootRef, ref);
97
+ const triggerRef = mergeRefs(setTriggerReference, rootRef, setRootElement, ref);
93
98
  const classes = tooltipSlotRecipe();
94
99
  const ariaDescribedBy = !hideFromScreenReader && content != null ? tooltipId : undefined;
95
100
  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: {