@moneyforward/mfui-components 4.9.0 → 4.10.1

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 +2 -0
  3. package/dist/src/DataTable/DataTableBody/DataTableBody.js +5 -2
  4. package/dist/src/DataTable/DataTableCell/DataTableCell.js +1 -1
  5. package/dist/src/DataTable/DataTableFilterButton/DataTableFilterButton.js +3 -9
  6. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +7 -3
  7. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.types.d.ts +18 -6
  8. package/dist/src/DataTable/hooks/useVirtualizedColumns.js +2 -1
  9. package/dist/src/DateTimeSelection/FilterDatePicker/CalendarPopover/CalendarPopover.js +1 -1
  10. package/dist/src/DateTimeSelection/FilterDatePicker/FilterDatePicker.js +2 -4
  11. package/dist/src/DateTimeSelection/FilterDateRangePicker/DateRangePickerContent/DateRangePickerContent.js +1 -1
  12. package/dist/src/DateTimeSelection/FilterDateRangePicker/FilterDateRangePicker.js +2 -4
  13. package/dist/src/DateTimeSelection/FilterMonthPicker/FilterMonthPicker.js +2 -4
  14. package/dist/src/DateTimeSelection/FilterMonthPicker/MonthPickerPopover/MonthPickerPopover.js +1 -1
  15. package/dist/src/DateTimeSelection/FilterMonthRangePicker/FilterMonthRangePicker.js +2 -3
  16. package/dist/src/DateTimeSelection/FilterMonthRangePicker/MonthRangePickerPopover/MonthRangePickerPopover.js +1 -1
  17. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.d.ts +1 -1
  18. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.js +2 -2
  19. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePicker.types.d.ts +34 -0
  20. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.js +18 -17
  21. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerProvider/BaseRangePickerProvider.types.d.ts +8 -0
  22. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/hooks/useDateRangeTriggerValueController.d.ts +2 -1
  23. package/dist/src/DateTimeSelection/shared/BaseRangePicker/BaseRangePickerTrigger/hooks/useDateRangeTriggerValueController.js +2 -1
  24. package/dist/src/DateTimeSelection/shared/DayCell/DayCell.js +2 -1
  25. package/dist/src/DateTimeSelection/shared/accessibility/constants.d.ts +1 -1
  26. package/dist/src/DateTimeSelection/shared/accessibility/constants.js +0 -4
  27. package/dist/src/DateTimeSelection/shared/accessibility/generateDateLabel.d.ts +4 -0
  28. package/dist/src/DateTimeSelection/shared/accessibility/generateDateLabel.js +4 -7
  29. package/dist/src/Dialog/Dialog.types.d.ts +17 -0
  30. package/dist/src/FileDropZone/FileDropZone.js +23 -6
  31. package/dist/src/Popover/Popover.d.ts +1 -1
  32. package/dist/src/Popover/Popover.js +118 -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/data-table-cell-slot-recipe.d.ts +1 -1
  55. package/dist/styled-system/recipes/data-table-cell-slot-recipe.js +4 -0
  56. package/dist/styled-system/recipes/day-cell-slot-recipe.d.ts +1 -1
  57. package/dist/styled-system/recipes/day-cell-slot-recipe.js +0 -4
  58. package/dist/styled-system/recipes/index.d.ts +2 -1
  59. package/dist/styled-system/recipes/index.js +1 -0
  60. package/dist/styled-system/recipes/popover-slot-recipe.d.ts +1 -1
  61. package/dist/styled-system/recipes/popover-slot-recipe.js +4 -0
  62. package/dist/styled-system/recipes/visually-hidden-slot-recipe.d.ts +33 -0
  63. package/dist/styled-system/recipes/visually-hidden-slot-recipe.js +28 -0
  64. package/dist/styles.css +46 -45
  65. package/dist/tsconfig.build.tsbuildinfo +1 -1
  66. package/package.json +3 -3
@@ -13,6 +13,23 @@ export type DialogProps = {
13
13
  * inline text, status indicators, or icons — because the dialog uses
14
14
  * `aria-labelledby` to announce the title and screen readers will read the
15
15
  * entire subtree. Avoid placing interactive controls inside `title`.
16
+ * Note that `<Skeleton />` is hidden from assistive technologies, so a
17
+ * Skeleton-only title contributes nothing to the dialog's accessible name.
18
+ * Pair it with `<VisuallyHidden>` text to give the dialog an accessible name.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <Dialog
23
+ * title={
24
+ * <>
25
+ * <VisuallyHidden>Loading title</VisuallyHidden>
26
+ * <span style={{ display: 'inline-block', width: '240px', height: '24px' }}>
27
+ * <Skeleton />
28
+ * </span>
29
+ * </>
30
+ * }
31
+ * />
32
+ * ```
16
33
  */
17
34
  title: ReactNode;
18
35
  /**
@@ -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) {
@@ -13,4 +13,4 @@ import { type PopoverProps } from './Popover.types';
13
13
  * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
14
14
  * See the "Layering" page in Storybook for the full layer order.
15
15
  */
16
- 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';
@@ -26,9 +27,11 @@ import { cx } from '../../styled-system/css';
26
27
  * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
27
28
  * See the "Layering" page in Storybook for the full layer order.
28
29
  */
29
- 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, }) {
30
- // Toggle event is the event triggered by open / close the Popover
31
- 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);
32
35
  const { isOpen: localIsOpen, toggle: localToggle, open: localOpen, close: localClose, } = useDisclosure({
33
36
  value: open,
34
37
  onToggle: onOpenStateChanged,
@@ -56,6 +59,82 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
56
59
  refs.setFloating(node);
57
60
  setFloatingElement(node);
58
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]);
59
138
  // Fire onOpen / onClose *only* when the open state actually changes.
60
139
  // We keep a ref of the previous open state so a new toggleEvent while already
61
140
  // open does not retrigger `onOpen`, which previously caused unexpected focus
@@ -63,6 +142,14 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
63
142
  const previousOpenRef = useRef(localIsOpen);
64
143
  useUpdateEffect(() => {
65
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;
66
153
  if (!wasOpen && localIsOpen) {
67
154
  // Transition: closed -> open
68
155
  onOpen?.(toggleEvent);
@@ -73,20 +160,32 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
73
160
  else if (wasOpen && !localIsOpen) {
74
161
  // Transition: open -> closed
75
162
  onClose?.(toggleEvent);
76
- if (!toggleEvent?.defaultPrevented && enableAutoFocusOnTrigger) {
77
- 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 });
78
169
  }
79
170
  }
80
171
  // Update tracker for next invocation
81
172
  previousOpenRef.current = localIsOpen;
82
- }, [localIsOpen, onOpen, onClose, enableAutoFocusOnPopover, enableAutoFocusOnTrigger]);
173
+ }, [
174
+ localIsOpen,
175
+ onOpen,
176
+ onClose,
177
+ enableAutoFocusOnPopover,
178
+ enableAutoFocusOnTrigger,
179
+ isFocusInsideOrLost,
180
+ returnFocusRef,
181
+ ]);
83
182
  const handleKeyDownTrigger = useCallback((event) => {
84
183
  switch (event.key) {
85
184
  case 'ArrowDown':
86
185
  case 'ArrowUp':
87
186
  event.stopPropagation();
88
187
  event.preventDefault();
89
- setToggleEvent(event);
188
+ setPendingChange({ nextOpen: true, event });
90
189
  localOpen();
91
190
  break;
92
191
  }
@@ -96,7 +195,7 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
96
195
  case 'Escape':
97
196
  event.stopPropagation();
98
197
  event.preventDefault();
99
- setToggleEvent(event);
198
+ setPendingChange({ nextOpen: false, event, source: 'escape-key' });
100
199
  localClose();
101
200
  break;
102
201
  }
@@ -155,7 +254,7 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
155
254
  }, [onBlur, refs.reference, refs.floating]);
156
255
  // Close the menu when clicking outside the menu
157
256
  useOnClickOutside(refs.floating, (event) => {
158
- setToggleEvent(undefined);
257
+ setPendingChange({ nextOpen: false, source: 'outside-press' });
159
258
  localClose();
160
259
  // Also trigger onBlur when clicking outside, since this represents the user
161
260
  // clicking away from the component entirely. We create a synthetic FocusEvent
@@ -170,18 +269,23 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
170
269
  timeStamp: event.timeStamp,
171
270
  nativeEvent: event,
172
271
  });
173
- }, { 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) });
174
278
  const options = useMemo(() => {
175
279
  const toggle = (event) => {
176
- setToggleEvent(event);
280
+ setPendingChange({ nextOpen: !localIsOpen, event });
177
281
  localToggle();
178
282
  };
179
283
  const open = (event) => {
180
- setToggleEvent(event);
284
+ setPendingChange({ nextOpen: true, event });
181
285
  localOpen();
182
286
  };
183
287
  const close = (event) => {
184
- setToggleEvent(event);
288
+ setPendingChange({ nextOpen: false, event });
185
289
  localClose();
186
290
  };
187
291
  return {
@@ -272,5 +376,5 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
272
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: {
273
377
  ...floatingStyles,
274
378
  ...(!enableAutoUnmount && !isPopoverOpen && { display: 'none' }),
275
- }, 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] }));
276
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { isValidElement } from 'react';
2
+ import { isProduction } from '../env/isProduction';
2
3
  /**
3
4
  * Checks if a React element is of a specific component type or wraps that component.
4
5
  *
@@ -79,7 +80,7 @@ export function isComponentOrWrapped(element, targetComponent) {
79
80
  const result = element.type(element.props);
80
81
  if (isValidElement(result) && result.type === targetComponent) {
81
82
  // Warn in development about better practice
82
- if (process.env['NODE_ENV'] !== 'production' && targetDisplayName) {
83
+ if (!isProduction() && targetDisplayName) {
83
84
  console.warn(`[MFUI] Wrapper component detected without displayName. ` +
84
85
  `This may cause issues if your wrapper uses React hooks. ` +
85
86
  `Please set displayName="${targetDisplayName}" on your wrapper component.`);