@moneyforward/mfui-components 4.8.0 → 4.10.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 (66) hide show
  1. package/dist/src/Badge/Badge.js +2 -1
  2. package/dist/src/DataTable/DataTable.types.d.ts +1 -0
  3. package/dist/src/DataTable/DataTableBody/DataTableBody.js +5 -2
  4. package/dist/src/DataTable/DataTableFilterButton/DataTableFilterButton.js +3 -9
  5. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +7 -3
  6. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.types.d.ts +18 -6
  7. package/dist/src/DataTable/hooks/useVirtualizedColumns.js +2 -1
  8. package/dist/src/DateTimeSelection/FilterDatePicker/CalendarPopover/CalendarPopover.js +1 -1
  9. package/dist/src/DateTimeSelection/FilterDatePicker/FilterDatePicker.js +2 -4
  10. package/dist/src/DateTimeSelection/FilterDateRangePicker/DateRangePickerContent/DateRangePickerContent.js +1 -1
  11. package/dist/src/DateTimeSelection/FilterDateRangePicker/FilterDateRangePicker.js +2 -4
  12. package/dist/src/DateTimeSelection/FilterMonthPicker/FilterMonthPicker.js +2 -4
  13. package/dist/src/DateTimeSelection/FilterMonthPicker/MonthPickerPopover/MonthPickerPopover.js +1 -1
  14. package/dist/src/DateTimeSelection/FilterMonthRangePicker/FilterMonthRangePicker.js +2 -3
  15. package/dist/src/DateTimeSelection/FilterMonthRangePicker/MonthRangePickerPopover/MonthRangePickerPopover.js +1 -1
  16. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.d.ts +1 -1
  17. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.js +2 -2
  18. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.types.d.ts +34 -0
  19. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.js +18 -17
  20. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.types.d.ts +8 -0
  21. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/hooks/useDateRangeTriggerValueController.d.ts +2 -1
  22. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/hooks/useDateRangeTriggerValueController.js +2 -1
  23. package/dist/src/DateTimeSelection/shared/DayCell/DayCell.js +2 -1
  24. package/dist/src/DateTimeSelection/shared/accessibility/constants.d.ts +1 -1
  25. package/dist/src/DateTimeSelection/shared/accessibility/constants.js +0 -4
  26. package/dist/src/DateTimeSelection/shared/accessibility/generateDateLabel.d.ts +4 -0
  27. package/dist/src/DateTimeSelection/shared/accessibility/generateDateLabel.js +4 -7
  28. package/dist/src/Dialog/Dialog.types.d.ts +17 -0
  29. package/dist/src/FileDropZone/FileDropZone.js +23 -6
  30. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +2 -2
  31. package/dist/src/Popover/Popover.d.ts +8 -1
  32. package/dist/src/Popover/Popover.js +125 -14
  33. package/dist/src/Popover/Popover.types.d.ts +16 -3
  34. package/dist/src/SectionMessage/SectionMessage.d.ts +6 -0
  35. package/dist/src/SectionMessage/SectionMessage.js +16 -1
  36. package/dist/src/SectionMessage/SectionMessage.types.d.ts +4 -0
  37. package/dist/src/Skeleton/Skeleton.d.ts +5 -2
  38. package/dist/src/Skeleton/Skeleton.js +5 -2
  39. package/dist/src/Skeleton/Skeleton.types.d.ts +9 -1
  40. package/dist/src/VisuallyHidden/VisuallyHidden.d.ts +17 -0
  41. package/dist/src/VisuallyHidden/VisuallyHidden.js +21 -0
  42. package/dist/src/VisuallyHidden/VisuallyHidden.types.d.ts +19 -0
  43. package/dist/src/VisuallyHidden/VisuallyHidden.types.js +1 -0
  44. package/dist/src/VisuallyHidden/index.d.ts +2 -0
  45. package/dist/src/VisuallyHidden/index.js +1 -0
  46. package/dist/src/index.d.ts +1 -0
  47. package/dist/src/index.js +1 -0
  48. package/dist/src/utilities/dom/usePopoverMenuPlacement.js +0 -5
  49. package/dist/src/utilities/env/isProduction.d.ts +14 -0
  50. package/dist/src/utilities/env/isProduction.js +22 -0
  51. package/dist/src/utilities/react/isComponentOrWrapped.js +2 -1
  52. package/dist/styled-system/recipes/badge-slot-recipe.d.ts +1 -1
  53. package/dist/styled-system/recipes/badge-slot-recipe.js +0 -4
  54. package/dist/styled-system/recipes/day-cell-slot-recipe.d.ts +1 -1
  55. package/dist/styled-system/recipes/day-cell-slot-recipe.js +0 -4
  56. package/dist/styled-system/recipes/index.d.ts +2 -1
  57. package/dist/styled-system/recipes/index.js +1 -0
  58. package/dist/styled-system/recipes/popover-slot-recipe.d.ts +1 -1
  59. package/dist/styled-system/recipes/popover-slot-recipe.js +4 -0
  60. package/dist/styled-system/recipes/visually-hidden-slot-recipe.d.ts +33 -0
  61. package/dist/styled-system/recipes/visually-hidden-slot-recipe.js +28 -0
  62. package/dist/styled-system/tokens/index.js +4 -0
  63. package/dist/styled-system/tokens/tokens.d.ts +1 -1
  64. package/dist/styles.css +29 -38
  65. package/dist/tsconfig.build.tsbuildinfo +1 -1
  66. package/package.json +2 -2
@@ -75,6 +75,8 @@ fileSelectButtonLabel = 'ファイルを選択', instructionText, fileCountLabel
75
75
  const buttonRef = useRef(null);
76
76
  const previousValueRef = useRef(value);
77
77
  const isUserInitiatedClearRef = useRef(false);
78
+ // Counts nested dragenter/dragleave pairs so moving over descendants does not drop the highlight
79
+ const dragDepthRef = useRef(0);
78
80
  const displayValues = isControlled ? value : internalValues;
79
81
  const errorMessages = (Array.isArray(errorMessage) ? errorMessage : [errorMessage]).filter((message) => Boolean(message));
80
82
  const fileBoxMaxWidthStyle = { maxWidth: fileBoxWidth };
@@ -141,25 +143,38 @@ fileSelectButtonLabel = 'ファイルを選択', instructionText, fileCountLabel
141
143
  if (isControlled) {
142
144
  resetFileInput();
143
145
  }
146
+ dragDepthRef.current = 0;
144
147
  setIsDragging(false);
145
148
  }, [isControlled, onChange, resetFileInput]);
146
149
  const handleDragEnter = useCallback((event) => {
147
150
  event.preventDefault();
148
151
  event.stopPropagation();
152
+ dragDepthRef.current += 1;
149
153
  setIsDragging(true);
150
154
  }, []);
151
155
  const handleDragLeave = useCallback((event) => {
152
156
  event.preventDefault();
153
157
  event.stopPropagation();
154
- setIsDragging(false);
158
+ /*
159
+ * `dragleave` also bubbles up from descendants, and moving into a child fires its `dragenter`
160
+ * before the container's `dragleave`. Only clear the highlight once every entered element has
161
+ * been left, so it stays applied for as long as the pointer is inside the drop zone.
162
+ */
163
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
164
+ if (dragDepthRef.current === 0) {
165
+ setIsDragging(false);
166
+ }
155
167
  }, []);
156
168
  const handleDragOver = useCallback((event) => {
157
169
  event.preventDefault();
158
170
  event.stopPropagation();
159
- }, []);
171
+ // eslint-disable-next-line no-param-reassign -- Setting the drop effect on the event is how the copy cursor is requested
172
+ event.dataTransfer.dropEffect = disabled ? 'none' : 'copy';
173
+ }, [disabled]);
160
174
  const handleDrop = useCallback((event) => {
161
175
  event.preventDefault();
162
176
  event.stopPropagation();
177
+ dragDepthRef.current = 0;
163
178
  setIsDragging(false);
164
179
  if (disabled) {
165
180
  return;
@@ -210,11 +225,13 @@ fileSelectButtonLabel = 'ファイルを選択', instructionText, fileCountLabel
210
225
  }
211
226
  }, [disabled]);
212
227
  const handleContainerClick = useCallback((event) => {
213
- // Only trigger file input if clicking directly on the container
214
- // and not on interactive elements within it
215
- if (event.target === event.currentTarget) {
216
- handleFileInputTrigger();
228
+ // A click anywhere in the drop zone opens the file dialog, except on the buttons
229
+ // and the `extraElement` content, which handle their own clicks
230
+ const target = event.target;
231
+ if (target.closest('button, [data-mfui-content="file-drop-zone-extra"]')) {
232
+ return;
217
233
  }
234
+ handleFileInputTrigger();
218
235
  }, [handleFileInputTrigger]);
219
236
  const renderDisplayValues = useMemo(() => {
220
237
  if (displayValues !== undefined && displayValues.length > 0) {
@@ -324,7 +324,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
324
324
  return (_jsxs(Tag, { className: cx(classes.infiniteScrollError, 'mfui-MultipleSelectBox__infiniteScrollError'), role: "alert", children: [_jsxs("div", { className: cx(classes.infiniteScrollErrorMessage, 'mfui-MultipleSelectBox__infiniteScrollErrorMessage'), "aria-live": "polite", children: [_jsx(Error, { "aria-hidden": true, className: cx(classes.infiniteScrollErrorIcon, 'mfui-MultipleSelectBox__infiniteScrollErrorIcon') }), _jsx(Typography, { variant: "body", children: infiniteScrollErrorMessage })] }), _jsx("div", { className: cx(classes.infiniteScrollErrorButton, 'mfui-MultipleSelectBox__infiniteScrollErrorButton'), children: _jsx(Button, { size: "small", onClick: (event) => {
325
325
  event.stopPropagation();
326
326
  retryInfiniteScroll();
327
- }, children: infiniteScrollRetryButtonText }) })] }));
327
+ }, children: infiniteScrollRetryButtonText }) })] }, "infinite-scroll-error"));
328
328
  }, [
329
329
  infiniteScrollError,
330
330
  classes.infiniteScrollError,
@@ -336,7 +336,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
336
336
  infiniteScrollRetryButtonText,
337
337
  ]);
338
338
  // Render infinite scroll loading indicator
339
- const renderInfiniteScrollLoading = useCallback(() => isInfiniteScrollLoading && enableInfiniteScroll ? (_jsx("div", { className: cx(classes.infiniteScrollLoading, 'mfui-MultipleSelectBox__infiniteScrollLoading'), children: _jsx(ProgressIndicator, {}) })) : null, [isInfiniteScrollLoading, enableInfiniteScroll, classes.infiniteScrollLoading]);
339
+ const renderInfiniteScrollLoading = useCallback(() => isInfiniteScrollLoading && enableInfiniteScroll ? (_jsx("div", { className: cx(classes.infiniteScrollLoading, 'mfui-MultipleSelectBox__infiniteScrollLoading'), children: _jsx(ProgressIndicator, {}) }, "infinite-scroll-loading")) : null, [isInfiniteScrollLoading, enableInfiniteScroll, classes.infiniteScrollLoading]);
340
340
  const searchNode = enableSearchOptions ? (_jsx(SearchBox, { ref: searchInputRef, enableClearButton: true, value: searchText, textBoxSize: "small", autoComplete: "off", "aria-label": "\u691C\u7D22\u3059\u308B", onChange: onSearchTextChange, ...searchBoxProps })) : null;
341
341
  const allOptionsControlsNode = enableAllOptionsControls ? (_jsxs(HStack, { justifyContent: "space-between", children: [_jsx(Button, { ref: selectAllButtonRef, size: "small", onClick: handleSelectAll, children: selectAllButtonProps?.label ?? 'すべて選択' }), _jsx(Button, { ref: clearAllButtonRef, size: "small", onClick: handleClearAll, children: clearAllButtonProps?.label ?? 'すべて解除' })] })) : null;
342
342
  const selectedCountNode = enableApplyControls || showSelectedCount ? (_jsx(Typography, { variant: "body", color: "neutral.600", children: enableApplyControls
@@ -5,5 +5,12 @@ import { type PopoverProps } from './Popover.types';
5
5
  *
6
6
  * You can control the open state of the Popover by passing the `open` and `onOpenStateChanged` props
7
7
  * Or just leave it as an uncontrolled popover, it will automatically handle by itself.
8
+ *
9
+ * ## Layering
10
+ *
11
+ * The popover content is drawn on the `--mfui-z-index-popover` layer (`6000`), above `Dialog` and `SidePane`
12
+ * and below `Toast` and `Tooltip`.
13
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
14
+ * See the "Layering" page in Storybook for the full layer order.
8
15
  */
9
- export declare function Popover({ renderTrigger, renderContent, open, onOpenStateChanged, onOpen, onClose, enableAutoFocusOnTrigger, enableAutoFocusOnPopover, minWidth, maxHeight, enableConstrainedContentWidth, contentProps, targetDOMNode, enableAutoUnmount, onBlur, allowedPlacements, enableViewportConstraint, enableAutomaticPortalTargetResolution, enableCenteredLayoutOnNarrowViewport, }: PopoverProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare function Popover({ renderTrigger, renderContent, open, onOpenStateChanged, onOpen, onClose, enableAutoFocusOnTrigger, returnFocusRef, enableAutoFocusOnPopover, minWidth, maxHeight, enableConstrainedContentWidth, contentProps, targetDOMNode, enableAutoUnmount, onBlur, allowedPlacements, enableViewportConstraint, enableAutomaticPortalTargetResolution, enableCenteredLayoutOnNarrowViewport, }: PopoverProps): import("react/jsx-runtime").JSX.Element;
@@ -8,6 +8,7 @@ import { useDisclosure } from '../utilities/state/useDisclosure';
8
8
  import { Portal, TargetDomNodeProvider, useAutomaticTargetDomNode } from '../Portal';
9
9
  import { useOnClickOutside } from '../utilities/dom/useOnClickOutside';
10
10
  import { getRootNode } from '../utilities/dom/getRootNode';
11
+ import { getDeepActiveElement } from '../utilities/dom/getDeepActiveElement';
11
12
  import { getTargetDomNode } from '../utilities/dom/getTargetDomNode';
12
13
  import { popoverSlotRecipe } from '../../styled-system/recipes';
13
14
  import { useUpdateEffect } from '../utilities/effect/useUpdateEffect';
@@ -18,10 +19,19 @@ import { cx } from '../../styled-system/css';
18
19
  *
19
20
  * You can control the open state of the Popover by passing the `open` and `onOpenStateChanged` props
20
21
  * Or just leave it as an uncontrolled popover, it will automatically handle by itself.
22
+ *
23
+ * ## Layering
24
+ *
25
+ * The popover content is drawn on the `--mfui-z-index-popover` layer (`6000`), above `Dialog` and `SidePane`
26
+ * and below `Toast` and `Tooltip`.
27
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
28
+ * See the "Layering" page in Storybook for the full layer order.
21
29
  */
22
- export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged, onOpen, onClose, enableAutoFocusOnTrigger = true, enableAutoFocusOnPopover = true, minWidth, maxHeight, enableConstrainedContentWidth = false, contentProps = {}, targetDOMNode, enableAutoUnmount = true, onBlur, allowedPlacements, enableViewportConstraint = true, enableAutomaticPortalTargetResolution = false, enableCenteredLayoutOnNarrowViewport = false, }) {
23
- // Toggle event is the event triggered by open / close the Popover
24
- const [toggleEvent, setToggleEvent] = useState();
30
+ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged, onOpen, onClose, enableAutoFocusOnTrigger = true, returnFocusRef, enableAutoFocusOnPopover = true, minWidth, maxHeight, enableConstrainedContentWidth = false, contentProps = {}, targetDOMNode, enableAutoUnmount = true, onBlur, allowedPlacements, enableViewportConstraint = true, enableAutomaticPortalTargetResolution = false, enableCenteredLayoutOnNarrowViewport = false, }) {
31
+ // The last open / close request made by the Popover itself: the next open state, its event, and,
32
+ // for Escape and click outside, how it closed. Read once by the open / close effect below.
33
+ const [pendingChange, setPendingChange] = useState(null);
34
+ const usedChangeRef = useRef(null);
25
35
  const { isOpen: localIsOpen, toggle: localToggle, open: localOpen, close: localClose, } = useDisclosure({
26
36
  value: open,
27
37
  onToggle: onOpenStateChanged,
@@ -49,6 +59,82 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
49
59
  refs.setFloating(node);
50
60
  setFloatingElement(node);
51
61
  }, [refs]);
62
+ /*
63
+ * MEMO: How focus returns to the trigger when the Popover closes
64
+ *
65
+ * Popover follows the rules of the native HTML popover:
66
+ * - After a click outside, focus does not return. The user has moved on to something else, so
67
+ * focus is left where the browser put it.
68
+ * - After Escape, focus returns. Popover's own Escape handler calls `preventDefault()`, which
69
+ * would otherwise skip the return, so Escape is tracked separately.
70
+ * - Focus returns only if it was still inside the Popover, or was lost when the Popover was
71
+ * removed. A Popover closed by its parent never takes focus away from where the user is.
72
+ * - Returning focus does not scroll the page.
73
+ * Components whose trigger button is not the reference element (the Filter pickers) pass that
74
+ * button as `returnFocusRef`.
75
+ * Before, focus always returned to the trigger, except after Escape. Because `focus()` scrolls to
76
+ * the element by default, a popover that was open lower on the page pulled the page down to its
77
+ * trigger when the user clicked anywhere else (e.g. the Popover docs page, where `WithMaxHeight`
78
+ * starts open).
79
+ *
80
+ * Options evaluated:
81
+ * - A. Fix only the story that starts open: one line, but products can hit the same jump.
82
+ * - B. Hide that story from the docs page: hides the problem instead of fixing it.
83
+ * - C. (picked) Follow the native popover rules above: fixes the cause for every component in
84
+ * one place and matches the HTML standard, Radix and the WAI-ARIA APG (Escape returns focus to
85
+ * the menu button or the element that opened the dialog). It changes visible focus behavior:
86
+ * no focus after a click outside, and focus after Escape.
87
+ * - C2. Pass a close reason to `onClose` and let each component decide: works, but adds public
88
+ * API and repeats the same rule in menus, pickers and DataTableFilterButton.
89
+ * - D. Return focus with `preventScroll: true` only: no jump, but focus is still taken away from
90
+ * what the user clicked, which the HTML standard does not do.
91
+ * - E. Rebuild Popover on the native `popover` attribute: the browser handles outside clicks,
92
+ * Escape, focus, and stacking itself. Not done yet, see the TODO below.
93
+ * - F. Native `popover` plus CSS anchor positioning: no positioning library at all, but too
94
+ * early (Baseline since January 2026, still partial support in Chrome and Firefox).
95
+ *
96
+ * C is the safest fix today, and it already behaves like E, so it does not block E later.
97
+ *
98
+ * TODO: Rebuild Popover on the native `popover` attribute (option E)
99
+ * - Scope: Popover, `useOnClickOutside`, `usePopoverMenuPlacement`, and the Portal helpers
100
+ * (around 900 lines), the 12 components built on Popover, and the 22 components that expose
101
+ * `targetDOMNode`, which the top layer makes mostly unnecessary (deprecate, then remove in
102
+ * a major version).
103
+ * - Blockers: jsdom does not implement the Popover API (jsdom/jsdom#3721, open since May 2024),
104
+ * so tests need a stub or a real browser runner. Top-layer stacking changes the order
105
+ * documented in `docs/Layering.mdx`. MFUI has no written browser support policy yet.
106
+ * - Timing: Popover API is Baseline "newly available" since January 2025, so "widely
107
+ * available" (30 months later) from around July 2027. `@floating-ui/dom` already detects
108
+ * `:popover-open` elements, so positioning can stay on Floating UI.
109
+ * - Order: test setup -> Popover core with the same public API -> components and docs ->
110
+ * deprecate `targetDOMNode`.
111
+ *
112
+ * Resources checked (October 2026):
113
+ * - HTML Standard, popover light dismiss and the hide popover algorithm: https://html.spec.whatwg.org/multipage/popover.html
114
+ * - HTML Standard, `activeElement` and focus fixup: https://html.spec.whatwg.org/multipage/interaction.html
115
+ * - WAI-ARIA APG, Menu pattern (Escape): https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
116
+ * - WAI-ARIA APG, Dialog pattern (focus on close): https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
117
+ * - MDN, HTMLElement.focus() `preventScroll`: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
118
+ * - MDN, button click focus (Safari does not focus on click): https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button
119
+ * - Radix Popover (`hasInteractedOutsideRef`): https://github.com/radix-ui/primitives/blob/main/packages/react/popover/src/popover.tsx
120
+ * - Floating UI FloatingFocusManager `returnFocus`: https://floating-ui.com/docs/FloatingFocusManager
121
+ * - Base UI Popover `finalFocus`: https://base-ui.com/react/components/popover
122
+ * - MDN, Popover API Baseline: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
123
+ * - MDN, anchor-name Baseline: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-name
124
+ * - Can I use, CSS anchor positioning: https://caniuse.com/css-anchor-positioning
125
+ * - web.dev, Baseline definitions: https://web.dev/baseline
126
+ * - jsdom Popover API issue: https://github.com/jsdom/jsdom/issues/3721
127
+ */
128
+ // Whether focus is still inside the Popover, or was lost to the body when the Popover was
129
+ // removed. Shadow DOM aware: looks through shadow roots for the element that really has focus,
130
+ // so it also works when the content is not in the same shadow tree as the trigger.
131
+ const isFocusInsideOrLost = useCallback(() => {
132
+ const ownerDocument = refs.reference.current?.ownerDocument ?? document;
133
+ const activeElement = getDeepActiveElement(ownerDocument);
134
+ if (!activeElement || activeElement === ownerDocument.body)
135
+ return true;
136
+ return !!refs.floating.current?.contains(activeElement);
137
+ }, [refs.reference, refs.floating]);
52
138
  // Fire onOpen / onClose *only* when the open state actually changes.
53
139
  // We keep a ref of the previous open state so a new toggleEvent while already
54
140
  // open does not retrigger `onOpen`, which previously caused unexpected focus
@@ -56,6 +142,14 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
56
142
  const previousOpenRef = useRef(localIsOpen);
57
143
  useUpdateEffect(() => {
58
144
  const wasOpen = previousOpenRef.current;
145
+ // Use the request only once, and only for the change it asked for. Any other change comes from the
146
+ // `open` prop, so a later change never reuses an old event (e.g. a prevented Escape).
147
+ const change = pendingChange && pendingChange !== usedChangeRef.current && pendingChange.nextOpen === localIsOpen
148
+ ? pendingChange
149
+ : null;
150
+ usedChangeRef.current = pendingChange;
151
+ const toggleEvent = change?.event;
152
+ const closeSource = change?.source;
59
153
  if (!wasOpen && localIsOpen) {
60
154
  // Transition: closed -> open
61
155
  onOpen?.(toggleEvent);
@@ -66,20 +160,32 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
66
160
  else if (wasOpen && !localIsOpen) {
67
161
  // Transition: open -> closed
68
162
  onClose?.(toggleEvent);
69
- if (!toggleEvent?.defaultPrevented && enableAutoFocusOnTrigger) {
70
- refs.reference.current?.focus();
163
+ if (enableAutoFocusOnTrigger &&
164
+ closeSource !== 'outside-press' &&
165
+ (closeSource === 'escape-key' || !toggleEvent?.defaultPrevented) &&
166
+ isFocusInsideOrLost()) {
167
+ // Like the native popover, returning focus does not scroll the page.
168
+ (returnFocusRef?.current ?? refs.reference.current)?.focus({ preventScroll: true });
71
169
  }
72
170
  }
73
171
  // Update tracker for next invocation
74
172
  previousOpenRef.current = localIsOpen;
75
- }, [localIsOpen, onOpen, onClose, enableAutoFocusOnPopover, enableAutoFocusOnTrigger]);
173
+ }, [
174
+ localIsOpen,
175
+ onOpen,
176
+ onClose,
177
+ enableAutoFocusOnPopover,
178
+ enableAutoFocusOnTrigger,
179
+ isFocusInsideOrLost,
180
+ returnFocusRef,
181
+ ]);
76
182
  const handleKeyDownTrigger = useCallback((event) => {
77
183
  switch (event.key) {
78
184
  case 'ArrowDown':
79
185
  case 'ArrowUp':
80
186
  event.stopPropagation();
81
187
  event.preventDefault();
82
- setToggleEvent(event);
188
+ setPendingChange({ nextOpen: true, event });
83
189
  localOpen();
84
190
  break;
85
191
  }
@@ -89,7 +195,7 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
89
195
  case 'Escape':
90
196
  event.stopPropagation();
91
197
  event.preventDefault();
92
- setToggleEvent(event);
198
+ setPendingChange({ nextOpen: false, event, source: 'escape-key' });
93
199
  localClose();
94
200
  break;
95
201
  }
@@ -148,7 +254,7 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
148
254
  }, [onBlur, refs.reference, refs.floating]);
149
255
  // Close the menu when clicking outside the menu
150
256
  useOnClickOutside(refs.floating, (event) => {
151
- setToggleEvent(undefined);
257
+ setPendingChange({ nextOpen: false, source: 'outside-press' });
152
258
  localClose();
153
259
  // Also trigger onBlur when clicking outside, since this represents the user
154
260
  // clicking away from the component entirely. We create a synthetic FocusEvent
@@ -163,18 +269,23 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
163
269
  timeStamp: event.timeStamp,
164
270
  nativeEvent: event,
165
271
  });
166
- }, { ignoreRefs: [refs.reference] });
272
+ },
273
+ // A closed Popover ignores clicks outside, like the native popover's light dismiss. Without this,
274
+ // a closed Popover kept mounted by `enableAutoUnmount={false}` reported a close and a blur on every click.
275
+ // Uses the shown state (`open` first), so a Popover kept open by a parent that refused a close still
276
+ // asks again on the next click outside.
277
+ { ignoreRefs: [refs.reference], disabled: !(open ?? localIsOpen) });
167
278
  const options = useMemo(() => {
168
279
  const toggle = (event) => {
169
- setToggleEvent(event);
280
+ setPendingChange({ nextOpen: !localIsOpen, event });
170
281
  localToggle();
171
282
  };
172
283
  const open = (event) => {
173
- setToggleEvent(event);
284
+ setPendingChange({ nextOpen: true, event });
174
285
  localOpen();
175
286
  };
176
287
  const close = (event) => {
177
- setToggleEvent(event);
288
+ setPendingChange({ nextOpen: false, event });
178
289
  localClose();
179
290
  };
180
291
  return {
@@ -265,5 +376,5 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
265
376
  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: {
266
377
  ...floatingStyles,
267
378
  ...(!enableAutoUnmount && !isPopoverOpen && { display: 'none' }),
268
- }, onKeyDown: handleKeyDownPopover, children: _jsx(TargetDomNodeProvider, { targetDomNode: floatingElement, children: _jsx(PopoverContextProvider, { children: renderContent(options) }) }) }) })) : null] }));
379
+ }, onKeyDown: handleKeyDownPopover, children: _jsx(TargetDomNodeProvider, { targetDomNode: floatingElement, children: _jsx("div", { className: cx(classes.contentWrapper, 'mfui-Popover__contentWrapper'), children: _jsx(PopoverContextProvider, { children: renderContent(options) }) }) }) }) })) : null] }));
269
380
  }
@@ -1,4 +1,4 @@
1
- import { type ComponentProps, type CSSProperties, type KeyboardEventHandler, type ReactNode, type SyntheticEvent, type FocusEventHandler } from 'react';
1
+ import { type ComponentProps, type CSSProperties, type KeyboardEventHandler, type ReactNode, type RefObject, type SyntheticEvent, type FocusEventHandler } from 'react';
2
2
  type Side = 'top' | 'right' | 'bottom' | 'left';
3
3
  type Alignment = 'start' | 'end';
4
4
  /**
@@ -102,8 +102,10 @@ export type PopoverProps = {
102
102
  onOpen?: (event?: SyntheticEvent) => void;
103
103
  /**
104
104
  * This callback function will be called with the Popover closed.
105
- * The default action is focusing on the Trigger.
106
- * You can prevent that action by using `event.preventDefault()` in the callback function.
105
+ * The default action is focusing on the Trigger, like the native HTML popover: not after a click outside, and only
106
+ * when focus is still inside the Popover. Focusing does not scroll the page.
107
+ * You can prevent that action by using `event.preventDefault()` in the callback function, except after Escape.
108
+ * To never return focus, use `enableAutoFocusOnTrigger={false}`.
107
109
  *
108
110
  * @param event - The trigger event which makes the Popover close, this param can be undefined if you use `toggle` or
109
111
  * `close` without passing the event parameter. If you don't pass the event, you can't prevent the default action.
@@ -115,6 +117,17 @@ export type PopoverProps = {
115
117
  * @default true - By default, it will automatically focus on the trigger element when the Popover closes.
116
118
  */
117
119
  enableAutoFocusOnTrigger?: boolean;
120
+ /**
121
+ * The element to return focus to when the Popover closes, instead of the Trigger.
122
+ * Use it when the element registered by `setTriggerRef` is not the button that opens the Popover.
123
+ *
124
+ * This prop is intended for internal MFUI components like the Filter pickers.
125
+ *
126
+ * @internal
127
+ *
128
+ * @default undefined
129
+ */
130
+ returnFocusRef?: RefObject<HTMLElement | null>;
118
131
  /**
119
132
  * The flag to enable or disable autofocus on the popover element when the Popover opens.
120
133
  *
@@ -4,6 +4,12 @@
4
4
  *
5
5
  * This component switches the variants of looks depends on the props `messageType` and `region`.
6
6
  *
7
+ * The wrapper is a live region whose urgency follows `messageType`:
8
+ * - `error`: sets `role="alert"` — announces immediately to screen readers.
9
+ * - `neutral` / `info` / `success` / `caution` / `busy` / `progress`: set `role="status"` — announces politely when the user is idle.
10
+ *
11
+ * A `role` or `aria-live` passed via props takes precedence over these defaults.
12
+ *
7
13
  * Also extends the props of `<div>` element (the wrapper).
8
14
  *
9
15
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div
@@ -6,12 +6,27 @@ import { sectionMessageSlotRecipe } from '../../styled-system/recipes';
6
6
  import { IconButton } from '../IconButton';
7
7
  import { ProgressIndicator } from '../ProgressIndicator';
8
8
  import { Typography } from '../Typography';
9
+ const messageTypeRoleMap = {
10
+ neutral: 'status',
11
+ info: 'status',
12
+ success: 'status',
13
+ error: 'alert',
14
+ caution: 'status',
15
+ busy: 'status',
16
+ progress: 'status',
17
+ };
9
18
  /**
10
19
  * The SectionMessage component is used to display an inline message inside a page or some container.
11
20
  * Note: We changed the name of the component from `InlineMessage` to `SectionMessage`.
12
21
  *
13
22
  * This component switches the variants of looks depends on the props `messageType` and `region`.
14
23
  *
24
+ * The wrapper is a live region whose urgency follows `messageType`:
25
+ * - `error`: sets `role="alert"` — announces immediately to screen readers.
26
+ * - `neutral` / `info` / `success` / `caution` / `busy` / `progress`: set `role="status"` — announces politely when the user is idle.
27
+ *
28
+ * A `role` or `aria-live` passed via props takes precedence over these defaults.
29
+ *
15
30
  * Also extends the props of `<div>` element (the wrapper).
16
31
  *
17
32
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div
@@ -36,5 +51,5 @@ export const SectionMessage = forwardRef(({ messageType = 'neutral', region = 'i
36
51
  progress: (_jsx(ProgressIndicator, { ...statusIconA11yProps, className: cx(classes.statusIcon, 'mfui-SectionMessage__statusIcon') })),
37
52
  }[messageType];
38
53
  const contentIsString = typeof children === 'string';
39
- return (_jsxs("div", { ref: ref, className: cx(classes.root, 'mfui-SectionMessage__root', className), ...props, role: "alert", children: [_jsxs("div", { className: cx(classes.topSection, 'mfui-SectionMessage__topSection'), children: [_jsxs("div", { className: cx(classes.content, 'mfui-SectionMessage__content'), children: [StatusIcon, contentIsString ? _jsx(Typography, { variant: "body", children: children }) : children] }), _jsxs("div", { className: cx(classes.rightSection, 'mfui-SectionMessage__rightSection'), children: [_jsx("div", { className: cx(classes.actionSlot, classes.desktopActionSlot, 'mfui-SectionMessage__actionSlot'), "data-mfui-content": "SectionMessage-action-slot-desktop", children: actionSlot }), onClose ? (_jsx(IconButton, { "aria-label": closeButtonProps?.['aria-label'] ?? '閉じる', onClick: onClose, children: _jsx(Close, { "aria-hidden": true, className: cx(classes.closeIcon, 'mfui-SectionMessage__closeIcon') }) })) : null] })] }), actionSlot && !disableResponsive ? (_jsx("div", { className: cx(classes.actionSlot, classes.mobileActionSlot, 'mfui-SectionMessage__actionSlot'), "data-mfui-content": "SectionMessage-action-slot-mobile", children: actionSlot })) : null] }));
54
+ return (_jsxs("div", { ref: ref, role: messageTypeRoleMap[messageType], className: cx(classes.root, 'mfui-SectionMessage__root', className), ...props, children: [_jsxs("div", { className: cx(classes.topSection, 'mfui-SectionMessage__topSection'), children: [_jsxs("div", { className: cx(classes.content, 'mfui-SectionMessage__content'), children: [StatusIcon, contentIsString ? _jsx(Typography, { variant: "body", children: children }) : children] }), _jsxs("div", { className: cx(classes.rightSection, 'mfui-SectionMessage__rightSection'), children: [_jsx("div", { className: cx(classes.actionSlot, classes.desktopActionSlot, 'mfui-SectionMessage__actionSlot'), "data-mfui-content": "SectionMessage-action-slot-desktop", children: actionSlot }), onClose ? (_jsx(IconButton, { "aria-label": closeButtonProps?.['aria-label'] ?? '閉じる', onClick: onClose, children: _jsx(Close, { "aria-hidden": true, className: cx(classes.closeIcon, 'mfui-SectionMessage__closeIcon') }) })) : null] })] }), actionSlot && !disableResponsive ? (_jsx("div", { className: cx(classes.actionSlot, classes.mobileActionSlot, 'mfui-SectionMessage__actionSlot'), "data-mfui-content": "SectionMessage-action-slot-mobile", children: actionSlot })) : null] }));
40
55
  });
@@ -9,6 +9,10 @@ export type SectionMessageProps = {
9
9
  /**
10
10
  * The type of the message
11
11
  *
12
+ * It also determines the live-region role of the wrapper:
13
+ * `error` sets `role="alert"` (assertive), and every other type sets `role="status"` (polite).
14
+ * Pass `role` or `aria-live` to override this default.
15
+ *
12
16
  * @default 'neutral'
13
17
  */
14
18
  messageType?: SectionMessageSlotRecipeVariant['messageType'];
@@ -1,5 +1,8 @@
1
- import { type SkeletonProps } from './Skeleton.types';
2
1
  /**
3
2
  * The Skeleton component is used to indicate loading state of content before the data gets loaded.
3
+ *
4
+ * The root element is decorative and hidden from assistive technologies with `aria-hidden="true"` by default.
4
5
  */
5
- export declare const Skeleton: import("react").ForwardRefExoticComponent<SkeletonProps & import("react").RefAttributes<HTMLDivElement>>;
6
+ export declare const Skeleton: import("react").ForwardRefExoticComponent<{
7
+ className?: string;
8
+ } & Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "children"> & import("react").RefAttributes<HTMLDivElement>>;
@@ -4,8 +4,11 @@ import { cx } from '../../styled-system/css';
4
4
  import { skeletonSlotRecipe } from '../../styled-system/recipes';
5
5
  /**
6
6
  * The Skeleton component is used to indicate loading state of content before the data gets loaded.
7
+ *
8
+ * The root element is decorative and hidden from assistive technologies with `aria-hidden="true"` by default.
7
9
  */
8
- export const Skeleton = forwardRef(({ className }, ref) => {
10
+ export const Skeleton = forwardRef(({ className, ...props }, ref) => {
9
11
  const classes = skeletonSlotRecipe();
10
- return _jsx("div", { ref: ref, className: cx(classes.root, 'mfui-Skeleton__root', className) });
12
+ // `aria-hidden` is placed before the spread so that consumers can override it.
13
+ return _jsx("div", { ref: ref, "aria-hidden": "true", className: cx(classes.root, 'mfui-Skeleton__root', className), ...props });
11
14
  });
@@ -1,9 +1,17 @@
1
+ import { type ComponentPropsWithoutRef } from 'react';
1
2
  /**
2
3
  * The props for the Skeleton component.
4
+ *
5
+ * The root element is decorative and is rendered with `aria-hidden="true"` by default, so it is
6
+ * omitted from the accessibility tree. Expose the loading state on a surrounding element instead
7
+ * (e.g. `role="status"` with visually hidden text, or `aria-busy`).
8
+ * Pass `aria-hidden={false}` to override the default.
9
+ *
10
+ * `children` is not accepted because the root is hidden from assistive technologies.
3
11
  */
4
12
  export type SkeletonProps = {
5
13
  /**
6
14
  * The additional class name for the root element.
7
15
  */
8
16
  className?: string;
9
- };
17
+ } & Omit<ComponentPropsWithoutRef<'div'>, 'children'>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The VisuallyHidden component hides its content visually while keeping it available to assistive technologies.
3
+ *
4
+ * Use it to provide text for screen readers that is not shown on screen, for example an accessible name
5
+ * for a heading that only renders a `<Skeleton />` while loading.
6
+ * Use it instead of `aria-label` on role-less elements such as `<span>` or `<div>`, where `aria-label` is prohibited.
7
+ *
8
+ * Do not combine it with `aria-hidden` or `display: none`, because that removes the content from the accessibility tree.
9
+ *
10
+ * Also extends the props of `<span>` element.
11
+ *
12
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
13
+ */
14
+ export declare const VisuallyHidden: import("react").ForwardRefExoticComponent<{
15
+ children?: import("react").ReactNode;
16
+ className?: string;
17
+ } & Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & import("react").RefAttributes<HTMLSpanElement>>;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cx } from '../../styled-system/css';
4
+ import { visuallyHiddenSlotRecipe } from '../../styled-system/recipes';
5
+ /**
6
+ * The VisuallyHidden component hides its content visually while keeping it available to assistive technologies.
7
+ *
8
+ * Use it to provide text for screen readers that is not shown on screen, for example an accessible name
9
+ * for a heading that only renders a `<Skeleton />` while loading.
10
+ * Use it instead of `aria-label` on role-less elements such as `<span>` or `<div>`, where `aria-label` is prohibited.
11
+ *
12
+ * Do not combine it with `aria-hidden` or `display: none`, because that removes the content from the accessibility tree.
13
+ *
14
+ * Also extends the props of `<span>` element.
15
+ *
16
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
17
+ */
18
+ export const VisuallyHidden = forwardRef(({ className, children, ...props }, ref) => {
19
+ const classes = visuallyHiddenSlotRecipe();
20
+ return (_jsx("span", { ref: ref, className: cx(classes.root, 'mfui-VisuallyHidden__root', className), ...props, children: children }));
21
+ });
@@ -0,0 +1,19 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ /**
3
+ * The props for the VisuallyHidden component.
4
+ *
5
+ * Also extends the props of `<span>` element.
6
+ */
7
+ export type VisuallyHiddenProps = {
8
+ /**
9
+ * The content that is hidden visually but still exposed to assistive technologies.
10
+ *
11
+ * Keep it to non-interactive content such as text. Focusable elements would receive keyboard
12
+ * focus while staying invisible, which confuses sighted keyboard users.
13
+ */
14
+ children?: ReactNode;
15
+ /**
16
+ * The additional class name for the root element.
17
+ */
18
+ className?: string;
19
+ } & ComponentPropsWithoutRef<'span'>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { VisuallyHidden } from './VisuallyHidden';
2
+ export type { VisuallyHiddenProps } from './VisuallyHidden.types';
@@ -0,0 +1 @@
1
+ export { VisuallyHidden } from './VisuallyHidden';
@@ -61,3 +61,4 @@ export * from './Toast';
61
61
  export * from './ToggleSwitch';
62
62
  export * from './Tooltip';
63
63
  export * from './Typography';
64
+ export * from './VisuallyHidden';
package/dist/src/index.js CHANGED
@@ -61,3 +61,4 @@ export * from './Toast';
61
61
  export * from './ToggleSwitch';
62
62
  export * from './Tooltip';
63
63
  export * from './Typography';
64
+ export * from './VisuallyHidden';
@@ -105,7 +105,6 @@ export function usePopoverMenuPlacement({ minWidth, maxHeight, allowedPlacements
105
105
  Object.assign(elements.floating.style, {
106
106
  maxWidth: `${String(viewportWidth - horizontalMargin * 2)}px`,
107
107
  maxHeight: maxHeightPx,
108
- overflow: 'hidden',
109
108
  minWidth: 'auto', // Reset minWidth for centered layout
110
109
  });
111
110
  }
@@ -114,8 +113,6 @@ export function usePopoverMenuPlacement({ minWidth, maxHeight, allowedPlacements
114
113
  Object.assign(elements.floating.style, {
115
114
  // Vertical padding for Popover height (12px) matching token: padding-main-container-vertical-comfort
116
115
  maxHeight: maxHeightPx,
117
- // Prevent root element from scrolling; child elements handle overflow
118
- overflow: 'hidden',
119
116
  // Constrain width to exactly match the trigger element
120
117
  minWidth: referenceWidthPx,
121
118
  maxWidth: referenceWidthPx,
@@ -128,8 +125,6 @@ export function usePopoverMenuPlacement({ minWidth, maxHeight, allowedPlacements
128
125
  maxWidth: `calc(${String(Math.max(0, availableWidth))}px - 14px)`,
129
126
  // Vertical padding for Popover height (12px) matching token: padding-main-container-vertical-comfort
130
127
  maxHeight: maxHeightPx,
131
- // Prevent root element from scrolling; child elements handle overflow
132
- overflow: 'hidden',
133
128
  // Make the menu to be resized at least to the width of the trigger
134
129
  minWidth: minWidth ?? referenceWidthPx,
135
130
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Returns whether the code runs in a production build, so that development-only warnings can be skipped.
3
+ *
4
+ * The read is written so that `tsc` emits it in the dot form `process.env.NODE_ENV`, which bundlers replace with a
5
+ * string literal. In a production bundle, the check therefore folds to a constant even when the browser has no
6
+ * `process` global.
7
+ *
8
+ * When nothing replaces the read and `process` is not defined, such as on the Storybook Vite dev server for
9
+ * unbundled source, the bare read throws a `ReferenceError`. That environment is treated as non-production:
10
+ * that is where the development warnings are meant to be seen.
11
+ *
12
+ * @returns `true` only when `NODE_ENV` is `'production'`.
13
+ */
14
+ export declare function isProduction(): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Returns whether the code runs in a production build, so that development-only warnings can be skipped.
3
+ *
4
+ * The read is written so that `tsc` emits it in the dot form `process.env.NODE_ENV`, which bundlers replace with a
5
+ * string literal. In a production bundle, the check therefore folds to a constant even when the browser has no
6
+ * `process` global.
7
+ *
8
+ * When nothing replaces the read and `process` is not defined, such as on the Storybook Vite dev server for
9
+ * unbundled source, the bare read throws a `ReferenceError`. That environment is treated as non-production:
10
+ * that is where the development warnings are meant to be seen.
11
+ *
12
+ * @returns `true` only when `NODE_ENV` is `'production'`.
13
+ */
14
+ export function isProduction() {
15
+ try {
16
+ // The cast lets `noPropertyAccessFromIndexSignature` accept the dot form, which bundlers only replace in this form.
17
+ return process.env.NODE_ENV === 'production';
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }