@moneyforward/mfui-components 4.10.2 → 4.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,6 @@ React components for basic UI parts and layouts.
9
9
  ## Library Requirements
10
10
 
11
11
  MFUI requires certain libraries. Please check their versions.
12
- You can also check the recommended environment by referring to the libraries used in the projects under the `examples/` directory.
13
12
 
14
13
  ### Requirements
15
14
 
@@ -53,6 +53,7 @@ const useDateRangePickerContextValue = ({ value, defaultValue, disabled = false,
53
53
  const [temporaryEnd, setTemporaryEnd] = useState(endDate);
54
54
  const [previewDate, setPreviewDate] = useState();
55
55
  // --- Focus Management ---
56
+ // Moves DOM focus into the grid; typing must never set it (see BaseRangePickerProvider)
56
57
  const [pendingFocusDate, setPendingFocusDate] = useState(null);
57
58
  useUpdateEffect(() => {
58
59
  if (startDate) {
@@ -98,15 +99,12 @@ const useDateRangePickerContextValue = ({ value, defaultValue, disabled = false,
98
99
  setViewingMonth(parsedDate);
99
100
  // Update temporary selection for immediate calendar visual feedback
100
101
  setTemporaryStart(parsedDate);
101
- // Set pending focus date so when calendar opens, it focuses on this date
102
- setPendingFocusDate(dayjs(parsedDate).format('YYYY-MM-DD'));
103
102
  }
104
103
  else if (value === '') {
105
104
  // Clear temporary selection if input is empty
106
105
  setTemporaryStart(undefined);
107
- setPendingFocusDate(null);
108
106
  }
109
- }, [endDateString, format, minDate, maxDate, setViewingMonth, setTemporaryStart, setPendingFocusDate]);
107
+ }, [endDateString, format, minDate, maxDate, setViewingMonth, setTemporaryStart]);
110
108
  const handleEndDateStringChange = useCallback((value) => {
111
109
  setDateStrings([startDateString, value]);
112
110
  // Parse and update both viewing month and temporary selection for immediate visual feedback
@@ -115,15 +113,12 @@ const useDateRangePickerContextValue = ({ value, defaultValue, disabled = false,
115
113
  setViewingMonth(parsedDate);
116
114
  // Update temporary selection for immediate calendar visual feedback
117
115
  setTemporaryEnd(parsedDate);
118
- // Set pending focus date so when calendar opens, it focuses on this date
119
- setPendingFocusDate(dayjs(parsedDate).format('YYYY-MM-DD'));
120
116
  }
121
117
  else if (value === '') {
122
118
  // Clear temporary selection if input is empty
123
119
  setTemporaryEnd(undefined);
124
- setPendingFocusDate(null);
125
120
  }
126
- }, [startDateString, format, minDate, maxDate, setViewingMonth, setTemporaryEnd, setPendingFocusDate]);
121
+ }, [startDateString, format, minDate, maxDate, setViewingMonth, setTemporaryEnd]);
127
122
  const handleClear = useCallback(() => {
128
123
  // handleChange already calls setDates and setDateStrings, so we don't need to call them again
129
124
  handleChange([undefined, undefined]);
@@ -11,6 +11,20 @@ import { dayjs } from '../../../utilities/date/dayjs';
11
11
  */
12
12
  function InternalBaseRangePicker({ startInputRef, endInputRef, disabled, invalid, caution, format, enableClearButton, clearButtonProps, targetDOMNode, onBlur, isOpen, open, close, onOpenStateChanged, enableAutoUnmount, allowedPlacements, enableViewportConstraint, minWidth, renderPopoverContent, startInputProps = {}, endInputProps = {}, calendarLocale = 'ja', pendingFocusDayjsFormat = 'YYYY-MM-DD', }) {
13
13
  const { setPendingFocusDate, temporaryStart, temporaryEnd, startDate, endDate, viewingMonth, setViewingMonth } = useBaseRangePickerContext();
14
+ // Moves DOM focus into the popover grid, onto the most relevant date
15
+ const focusGridDate = useCallback(() => {
16
+ // Determine which date to focus on with priority:
17
+ // 1. Temporary start date (if exists - from typed input)
18
+ // 2. Temporary end date (if exists and no start date - from typed input)
19
+ // 3. Selected start date (if exists)
20
+ // 4. Selected end date (if exists and no start date)
21
+ // 5. Current date (today)
22
+ // 6. First non-disabled cell (handled by CalendarGrid / MonthGrid automatically)
23
+ const dateToFocus = temporaryStart ?? temporaryEnd ?? startDate ?? endDate ?? new Date();
24
+ // Set the pending focus key so the popover grid can focus the matching cell when it renders.
25
+ // Must match DateCell `data-mfui-date` (YYYY-MM-DD) or MonthCell `data-mfui-month` (YYYY-MM).
26
+ setPendingFocusDate(dayjs(dateToFocus).format(pendingFocusDayjsFormat));
27
+ }, [temporaryStart, temporaryEnd, startDate, endDate, setPendingFocusDate, pendingFocusDayjsFormat]);
14
28
  // Custom onOpen handler to prevent auto-focus when clicking on input elements
15
29
  // and implement smart focus management for calendar dates
16
30
  const handlePopoverOpen = useCallback((event) => {
@@ -25,47 +39,21 @@ function InternalBaseRangePicker({ startInputRef, endInputRef, disabled, invalid
25
39
  }
26
40
  // For all other cases (calendar button, keyboard navigation, etc.),
27
41
  // implement smart focus management for the calendar
28
- // Determine which date to focus on with priority:
29
- // 1. Temporary start date (if exists - from typed input)
30
- // 2. Temporary end date (if exists and no start date - from typed input)
31
- // 3. Selected start date (if exists)
32
- // 4. Selected end date (if exists and no start date)
33
- // 5. Current date (today)
34
- // 6. First non-disabled cell (handled by CalendarGrid / MonthGrid automatically)
35
- let dateToFocus;
36
- if (temporaryStart) {
37
- dateToFocus = temporaryStart;
38
- }
39
- else if (temporaryEnd) {
40
- dateToFocus = temporaryEnd;
41
- }
42
- else if (startDate) {
43
- dateToFocus = startDate;
44
- }
45
- else if (endDate) {
46
- dateToFocus = endDate;
47
- }
48
- else {
49
- // Default to today's date - the grid will focus the first non-disabled cell if needed
50
- dateToFocus = new Date();
51
- }
52
- // Set the pending focus key so the popover grid can focus the matching cell when it renders.
53
- // Must match DateCell `data-mfui-date` (YYYY-MM-DD) or MonthCell `data-mfui-month` (YYYY-MM).
54
- const formattedDate = dayjs(dateToFocus).format(pendingFocusDayjsFormat);
55
- setPendingFocusDate(formattedDate);
42
+ focusGridDate();
56
43
  open();
57
- }, [
58
- startInputRef,
59
- endInputRef,
60
- temporaryStart,
61
- temporaryEnd,
62
- startDate,
63
- endDate,
64
- setPendingFocusDate,
65
- open,
66
- pendingFocusDayjsFormat,
67
- ]);
68
- return (_jsx(Popover, { enableAutomaticPortalTargetResolution: true, open: isOpen, allowedPlacements: allowedPlacements, enableViewportConstraint: enableViewportConstraint, minWidth: minWidth, renderTrigger: ({ setTriggerRef, handleTriggerBlur, handleTriggerKeyDown, openPopover }) => (_jsx(BaseRangePickerTrigger, { ref: setTriggerRef, disabled: disabled, invalid: invalid, caution: caution, format: format, enableClearValue: enableClearButton, clearButtonProps: clearButtonProps, startInputRef: startInputRef, endInputRef: endInputRef, startInputProps: startInputProps, endInputProps: endInputProps, popoverOpenFunction: openPopover, calendarLocale: calendarLocale, onBlur: handleTriggerBlur, onKeyDown: handleTriggerKeyDown })), renderContent: () => renderPopoverContent({
44
+ }, [startInputRef, endInputRef, focusGridDate, open]);
45
+ // Popover fires `onOpen` only on a closed -> open transition, so Up/Down while the popover is
46
+ // already open (e.g. opened by clicking an input) has to move focus into the grid here.
47
+ const createTriggerKeyDownHandler = useCallback((handleTriggerKeyDown) => (event) => {
48
+ if (isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
49
+ event.stopPropagation();
50
+ event.preventDefault();
51
+ focusGridDate();
52
+ return;
53
+ }
54
+ handleTriggerKeyDown(event);
55
+ }, [isOpen, focusGridDate]);
56
+ return (_jsx(Popover, { enableAutomaticPortalTargetResolution: true, open: isOpen, allowedPlacements: allowedPlacements, enableViewportConstraint: enableViewportConstraint, minWidth: minWidth, renderTrigger: ({ setTriggerRef, handleTriggerBlur, handleTriggerKeyDown, openPopover }) => (_jsx(BaseRangePickerTrigger, { ref: setTriggerRef, disabled: disabled, invalid: invalid, caution: caution, format: format, enableClearValue: enableClearButton, clearButtonProps: clearButtonProps, startInputRef: startInputRef, endInputRef: endInputRef, startInputProps: startInputProps, endInputProps: endInputProps, popoverOpenFunction: openPopover, calendarLocale: calendarLocale, onBlur: handleTriggerBlur, onKeyDown: createTriggerKeyDownHandler(handleTriggerKeyDown) })), renderContent: () => renderPopoverContent({
69
57
  viewingValue: viewingMonth,
70
58
  setViewingValue: setViewingMonth,
71
59
  value: [temporaryStart, temporaryEnd],
@@ -84,5 +72,5 @@ export function BaseRangePicker({ value, defaultValue, disabled, invalid, cautio
84
72
  // Default renderPopoverContent implementation for backward compatibility
85
73
  const defaultRenderPopoverContent = useCallback(() => _jsx(BaseRangePickerPopover, { calendarLocale: calendarLocale }), [calendarLocale]);
86
74
  const finalRenderPopoverContent = renderPopoverContent ?? defaultRenderPopoverContent;
87
- return (_jsx(BaseRangePickerProvider, { value: value, defaultValue: defaultValue, disabled: disabled, format: format, isOpen: isOpen, open: open, close: close, disableAutoOpen: disableAutoOpen, disableAutoSwap: disableAutoSwap, minDate: minDate, maxDate: maxDate, initialDisplayedMonths: initialDisplayedMonths, initialViewingDate: initialViewingDate, customFormatValue: customFormatValue, pendingFocusDayjsFormat: pendingFocusDayjsFormat, onChange: onChange, children: _jsx(InternalBaseRangePicker, { startInputRef: startInputRef, endInputRef: endInputRef, disabled: disabled, invalid: invalid, caution: caution, format: format, enableClearButton: enableClearButton, clearButtonProps: clearButtonProps, targetDOMNode: targetDOMNode, allowedPlacements: allowedPlacements, enableViewportConstraint: enableViewportConstraint, enableAutoUnmount: enableAutoUnmount, minWidth: minWidth, startInputProps: startInputProps, endInputProps: endInputProps, isOpen: isOpen, open: open, close: close, renderPopoverContent: finalRenderPopoverContent, calendarLocale: calendarLocale, pendingFocusDayjsFormat: pendingFocusDayjsFormat, onBlur: onBlur, onOpenStateChanged: onOpenStateChanged }) }));
75
+ return (_jsx(BaseRangePickerProvider, { value: value, defaultValue: defaultValue, disabled: disabled, format: format, isOpen: isOpen, open: open, close: close, disableAutoOpen: disableAutoOpen, disableAutoSwap: disableAutoSwap, minDate: minDate, maxDate: maxDate, initialDisplayedMonths: initialDisplayedMonths, initialViewingDate: initialViewingDate, customFormatValue: customFormatValue, onChange: onChange, children: _jsx(InternalBaseRangePicker, { startInputRef: startInputRef, endInputRef: endInputRef, disabled: disabled, invalid: invalid, caution: caution, format: format, enableClearButton: enableClearButton, clearButtonProps: clearButtonProps, targetDOMNode: targetDOMNode, allowedPlacements: allowedPlacements, enableViewportConstraint: enableViewportConstraint, enableAutoUnmount: enableAutoUnmount, minWidth: minWidth, startInputProps: startInputProps, endInputProps: endInputProps, isOpen: isOpen, open: open, close: close, renderPopoverContent: finalRenderPopoverContent, calendarLocale: calendarLocale, pendingFocusDayjsFormat: pendingFocusDayjsFormat, onBlur: onBlur, onOpenStateChanged: onOpenStateChanged }) }));
88
76
  }
@@ -98,7 +98,7 @@ function autoCompletePartialRange(dates) {
98
98
  }
99
99
  return dates;
100
100
  }
101
- const useBaseRangePickerContextValue = ({ value, defaultValue, disabled = false, onChange, format = 'YYYY/MM/DD', isOpen = false, open = noop, close = noop, disableAutoOpen, disableAutoSwap = false, minDate, maxDate, initialDisplayedMonths = 'currentAndNext', initialViewingDate, customFormatValue, pendingFocusDayjsFormat = 'YYYY-MM-DD', }) => {
101
+ const useBaseRangePickerContextValue = ({ value, defaultValue, disabled = false, onChange, format = 'YYYY/MM/DD', isOpen = false, open = noop, close = noop, disableAutoOpen, disableAutoSwap = false, minDate, maxDate, initialDisplayedMonths = 'currentAndNext', initialViewingDate, customFormatValue, }) => {
102
102
  const formatDate = useMemo(() => customFormatValue ?? ((d) => dayjs(d).format(format)), [customFormatValue, format]);
103
103
  const [dates, setDates] = useTransformedState(defaultValue ?? [undefined, undefined], normalizeToStartOfDay);
104
104
  const [dateStrings, setDateStrings] = useState(() => {
@@ -122,6 +122,10 @@ const useBaseRangePickerContextValue = ({ value, defaultValue, disabled = false,
122
122
  const [temporaryEnd, setTemporaryEnd] = useState(endDate);
123
123
  const [previewDate, setPreviewDate] = useState();
124
124
  // --- Focus Management ---
125
+ // `pendingFocusDate` moves real DOM focus into the grid, so it must only be set by deliberate
126
+ // navigation (opening the popover, Up/Down). Typing must never write it: the typed date is
127
+ // highlighted through `viewingMonth` and `temporaryStart`/`temporaryEnd` instead, so the caret
128
+ // stays in the input and a partially typed date is not committed on blur.
125
129
  const [pendingFocusDate, setPendingFocusDate] = useState(null);
126
130
  // The committed range anchors the view on its start; the end is only a fallback
127
131
  // for a range whose start has been cleared. Keeping this in one effect avoids the
@@ -182,24 +186,12 @@ const useBaseRangePickerContextValue = ({ value, defaultValue, disabled = false,
182
186
  setViewingMonth(newDate);
183
187
  // Update temporary selection for immediate calendar visual feedback
184
188
  setTemporaryStart(newDate);
185
- // Set pending focus date so when calendar opens, it focuses on this date
186
- setPendingFocusDate(dayjs(newDate).format(pendingFocusDayjsFormat));
187
189
  }
188
190
  else if (value === '') {
189
191
  // Clear temporary selection if input is empty
190
192
  setTemporaryStart(undefined);
191
- setPendingFocusDate(null);
192
193
  }
193
- }, [
194
- endDateString,
195
- format,
196
- minDate,
197
- maxDate,
198
- setViewingMonth,
199
- setTemporaryStart,
200
- setPendingFocusDate,
201
- pendingFocusDayjsFormat,
202
- ]);
194
+ }, [endDateString, format, minDate, maxDate, setViewingMonth, setTemporaryStart]);
203
195
  const handleEndDateStringChange = useCallback((value) => {
204
196
  setDateStrings([startDateString, value]);
205
197
  // Parse and update both viewing month and temporary selection for immediate visual feedback
@@ -211,24 +203,12 @@ const useBaseRangePickerContextValue = ({ value, defaultValue, disabled = false,
211
203
  setViewingMonth(newDate);
212
204
  // Update temporary selection for immediate calendar visual feedback
213
205
  setTemporaryEnd(newDate);
214
- // Set pending focus date so when calendar opens, it focuses on this date
215
- setPendingFocusDate(dayjs(newDate).format(pendingFocusDayjsFormat));
216
206
  }
217
207
  else if (value === '') {
218
208
  // Clear temporary selection if input is empty
219
209
  setTemporaryEnd(undefined);
220
- setPendingFocusDate(null);
221
210
  }
222
- }, [
223
- startDateString,
224
- format,
225
- minDate,
226
- maxDate,
227
- setViewingMonth,
228
- setTemporaryEnd,
229
- setPendingFocusDate,
230
- pendingFocusDayjsFormat,
231
- ]);
211
+ }, [startDateString, format, minDate, maxDate, setViewingMonth, setTemporaryEnd]);
232
212
  const handleClear = useCallback(() => {
233
213
  // handleChange already calls setDates and setDateStrings, so we don't need to call them again
234
214
  handleChange([undefined, undefined]);
@@ -114,14 +114,4 @@ export type BaseRangePickerProviderProps = {
114
114
  * @returns The formatted string to display
115
115
  */
116
116
  customFormatValue?: (date: Date) => string;
117
- /**
118
- * dayjs format string for `pendingFocusDate` so it matches the focused grid cell's `data-mfui-*` attribute.
119
- *
120
- * @internal
121
- *
122
- * @default 'YYYY-MM-DD' — matches {@link DateCell} `data-mfui-date`
123
- *
124
- * @example 'YYYY-MM' — matches {@link MonthCell} `data-mfui-month` (MonthRangePicker)
125
- */
126
- pendingFocusDayjsFormat?: 'YYYY-MM-DD' | 'YYYY-MM';
127
117
  };
@@ -65,14 +65,18 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
65
65
  if (activeElement?.getAttribute('role') === 'option' && optionPanelRef.current?.contains(activeElement)) {
66
66
  return;
67
67
  }
68
- // When loading, focus on the option panel root div to enable ESC key functionality
69
- if (loading) {
68
+ // When search is enabled, focus the search input regardless of the selected option or loading state.
69
+ // Moving the focus away while the user is typing would commit or discard the IME composition.
70
+ if (enableSearchOptions && searchInputRef.current) {
71
+ searchInputRef.current.focus({ preventScroll: true });
72
+ }
73
+ else if (loading) {
74
+ // When loading without a search input, there is no option to focus yet, so focus the option panel
75
+ // root div to enable ESC key functionality
70
76
  optionPanelRef.current?.focus({ preventScroll: true });
71
- return;
72
77
  }
73
- // When search is enabled, focus the search input regardless of the selected option
74
- if (enableSearchOptions) {
75
- searchInputRef.current?.focus({ preventScroll: true });
78
+ if (loading) {
79
+ return;
76
80
  }
77
81
  // Handle auto-scroll independently of search input focus
78
82
  // This allows both features to work together
@@ -1,5 +1,34 @@
1
1
  import { useCallback } from 'react';
2
+ import { getDeepActiveElement } from './getDeepActiveElement';
2
3
  import { getFocusableNodes } from './getFocusableNodes';
4
+ /**
5
+ * Returns the focused node as seen by the focus trap.
6
+ *
7
+ * `document.activeElement` stops at the shadow host when the wrapper is rendered inside a shadow root,
8
+ * so the lookup starts from the wrapper's own root node (this also works for closed shadow roots) and
9
+ * follows nested shadow roots down to the deepest focused element.
10
+ * That element is then mapped back onto `focusableNodes` by ascending shadow boundaries only, so focus
11
+ * inside a web component that is itself a focusable node of the trap still resolves to that host.
12
+ *
13
+ * @param wrapper - The focus trap container.
14
+ *
15
+ * @param focusableNodes - The focusable nodes inside the wrapper.
16
+ *
17
+ * @returns The focusable node holding focus, or the deepest focused element when none of them does.
18
+ */
19
+ const getFocusingNode = (wrapper, focusableNodes) => {
20
+ const rootNode = wrapper.getRootNode();
21
+ const deepActiveElement = getDeepActiveElement(rootNode instanceof ShadowRoot ? rootNode : wrapper.ownerDocument);
22
+ let current = deepActiveElement;
23
+ while (current) {
24
+ if (focusableNodes.includes(current))
25
+ return current;
26
+ const currentRootNode = current.getRootNode();
27
+ // Only a ShadowRoot has a host to continue from; a Document ends the walk.
28
+ current = currentRootNode instanceof ShadowRoot ? currentRootNode.host : null;
29
+ }
30
+ return deepActiveElement;
31
+ };
3
32
  /**
4
33
  * Hook to trap focus within a container.
5
34
  *
@@ -16,8 +45,8 @@ export function useFocusTrap({ onKeyDown, onPressingTabOnNotFocusableElement } =
16
45
  if (event.key !== 'Tab')
17
46
  return;
18
47
  const wrapper = event.currentTarget;
19
- const focusingElement = wrapper.ownerDocument.activeElement;
20
48
  const focusableNodes = getFocusableNodes(wrapper);
49
+ const focusingElement = getFocusingNode(wrapper, focusableNodes);
21
50
  const [firstFocusableNode] = focusableNodes;
22
51
  const lastFocusableNode = focusableNodes.at(-1);
23
52
  const focusingOnFirstNode = focusingElement === firstFocusableNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneyforward/mfui-components",
3
- "version": "4.10.2",
3
+ "version": "4.10.4",
4
4
  "description": "React UI Component Library for all Money Forward products",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,11 +31,11 @@
31
31
  ],
32
32
  "devDependencies": {
33
33
  "@pandacss/dev": "1.12.1",
34
- "@storybook/addon-a11y": "10.6.1",
35
- "@storybook/addon-docs": "10.6.1",
36
- "@storybook/addon-links": "10.6.1",
34
+ "@storybook/addon-a11y": "10.6.5",
35
+ "@storybook/addon-docs": "10.6.5",
36
+ "@storybook/addon-links": "10.6.5",
37
37
  "@storybook/addon-mcp": "0.7.0",
38
- "@storybook/react-vite": "10.6.1",
38
+ "@storybook/react-vite": "10.6.5",
39
39
  "@testing-library/jest-dom": "6.9.1",
40
40
  "@testing-library/react": "16.3.3",
41
41
  "@testing-library/user-event": "14.6.8",
@@ -43,15 +43,15 @@
43
43
  "@types/react": "19.2.9",
44
44
  "@types/react-dom": "19.2.3",
45
45
  "@vitejs/plugin-react": "5.2.0",
46
- "@vitest/coverage-v8": "4.1.11",
46
+ "@vitest/coverage-v8": "4.1.12",
47
47
  "jsdom": "26.1.0",
48
48
  "oxlint-config-mfui": "^1.0.0",
49
49
  "react": "19.2.3",
50
50
  "react-dom": "19.2.3",
51
- "storybook": "10.6.2",
51
+ "storybook": "10.6.5",
52
52
  "ts-node": "10.9.2",
53
53
  "tsx": "4.23.15",
54
- "vitest": "4.1.11"
54
+ "vitest": "4.1.12"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "react": ">=18",