@moneyforward/mfui-components 4.3.1 → 4.5.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 (53) hide show
  1. package/dist/src/Accordion/Accordion.d.ts +21 -1
  2. package/dist/src/Accordion/Accordion.js +28 -4
  3. package/dist/src/Accordion/Accordion.types.d.ts +21 -0
  4. package/dist/src/Breadcrumbs/Breadcrumbs.js +1 -1
  5. package/dist/src/CheckboxCard/CheckboxCard.js +3 -1
  6. package/dist/src/DataTable/DataTable.js +9 -2
  7. package/dist/src/DataTable/DataTable.types.d.ts +15 -0
  8. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +3 -1
  9. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +19 -1
  10. package/dist/src/DataTable/DataTableRow/DataTableRow.js +4 -1
  11. package/dist/src/DataTable/DataTableRow/DataTableRow.types.d.ts +23 -1
  12. package/dist/src/DateTimeSelection/DatePicker/DatePickerDateCell/index.d.ts +1 -1
  13. package/dist/src/DateTimeSelection/DatePicker/DatePickerDateCell/index.js +0 -1
  14. package/dist/src/DateTimeSelection/shared/CalendarGrid/CalendarGrid.js +1 -3
  15. package/dist/src/Dialog/Dialog.d.ts +1 -1
  16. package/dist/src/Dialog/Dialog.types.d.ts +1 -1
  17. package/dist/src/MainNavigation/BaseMainNavigation.d.ts +1 -1
  18. package/dist/src/MainNavigation/BaseMainNavigation.js +2 -2
  19. package/dist/src/MainNavigation/MainNavigation.js +2 -1
  20. package/dist/src/MainNavigation/MainNavigation.types.d.ts +9 -0
  21. package/dist/src/MainNavigation/NarrowViewportMainNavigation.js +2 -2
  22. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +27 -18
  23. package/dist/src/MultipleSelectBox/hooks/useInitialFocusOnOptionPanelOpen.js +1 -1
  24. package/dist/src/NavigationList/NavigationListItem/NavigationListItem.d.ts +3 -0
  25. package/dist/src/NavigationList/NavigationListItem/NavigationListItem.js +6 -2
  26. package/dist/src/NavigationList/NavigationListItem/NavigationListItem.types.d.ts +2 -1
  27. package/dist/src/NumberBox/NumberBox.d.ts +5 -0
  28. package/dist/src/NumberBox/NumberBox.js +53 -2
  29. package/dist/src/NumberBox/NumberBox.types.d.ts +35 -0
  30. package/dist/src/Pagination/Pagination.js +4 -1
  31. package/dist/src/Popover/Popover.js +1 -2
  32. package/dist/src/RadioButtonCard/RadioButtonCard.js +3 -1
  33. package/dist/src/SelectBox/SelectBox.js +20 -11
  34. package/dist/src/SelectBox/utils/getFlatOptions.js +1 -1
  35. package/dist/src/SidePane/SidePane.js +87 -7
  36. package/dist/src/SidePane/SidePane.types.d.ts +39 -0
  37. package/dist/src/SubNavigation/SubNavigation.d.ts +10 -1
  38. package/dist/src/SubNavigation/SubNavigation.js +14 -3
  39. package/dist/src/SubNavigation/SubNavigation.types.d.ts +27 -1
  40. package/dist/src/Tabs/hooks/useTabRegistry.js +5 -5
  41. package/dist/src/ToggleSwitch/index.d.ts +1 -1
  42. package/dist/src/ToggleSwitch/index.js +0 -1
  43. package/dist/src/utilities/dom/containsAcrossShadowRoots.d.ts +21 -0
  44. package/dist/src/utilities/dom/containsAcrossShadowRoots.js +33 -0
  45. package/dist/src/utilities/dom/useFocusTrap.js +1 -1
  46. package/dist/src/utilities/dom/useTableEdgeCellMarkers.d.ts +56 -0
  47. package/dist/src/utilities/dom/useTableEdgeCellMarkers.js +223 -0
  48. package/dist/styled-system/recipes/data-table-cell-slot-recipe.js +4 -2
  49. package/dist/styles.css +20 -4
  50. package/dist/tsconfig.build.tsbuildinfo +1 -1
  51. package/package.json +1 -1
  52. /package/dist/src/{MultipleSelectBox/utils → utilities/dom}/getDeepActiveElement.d.ts +0 -0
  53. /package/dist/src/{MultipleSelectBox/utils → utilities/dom}/getDeepActiveElement.js +0 -0
@@ -102,14 +102,49 @@ const mapRawCursorToDisplay = (formatted, rawCursor) => {
102
102
  }
103
103
  return formatted.length;
104
104
  };
105
+ /**
106
+ * Count the digit characters in a raw numeric string.
107
+ * The minus sign and the decimal point are not digits, so they are not counted.
108
+ *
109
+ * @param raw - The raw numeric string.
110
+ */
111
+ const countDigits = (raw) => (raw.match(/\d/g) ?? []).length;
112
+ /**
113
+ * Count the digit characters in the integer part of a raw numeric string, i.e. before the
114
+ * decimal point. `maxIntegerDigits` caps this count independently of `decimalPlaces`, so the
115
+ * two limits stack: `maxIntegerDigits={5}` with `decimalPlaces={2}` allows up to 7 digits total.
116
+ *
117
+ * @param raw - The raw numeric string.
118
+ */
119
+ const countIntegerDigits = (raw) => {
120
+ const dotIndex = raw.indexOf('.');
121
+ return countDigits(dotIndex === -1 ? raw : raw.slice(0, dotIndex));
122
+ };
123
+ /**
124
+ * Count the decimal places a raw numeric string occupies.
125
+ * A trailing dot counts as one place because it commits the value to a decimal part:
126
+ * that is what lets `decimalPlaces={0}` reject the dot itself while `decimalPlaces={2}`
127
+ * still allows the in-progress `123.`.
128
+ *
129
+ * @param raw - The raw numeric string.
130
+ */
131
+ const countDecimalPlaces = (raw) => {
132
+ const dotIndex = raw.indexOf('.');
133
+ if (dotIndex === -1)
134
+ return 0;
135
+ return Math.max(raw.length - dotIndex - 1, 1);
136
+ };
105
137
  /**
106
138
  * NumberBox is a numeric input component that wraps TextBox.
107
139
  * It provides real-time comma formatting, full-width digit conversion,
108
140
  * and ignores invalid characters.
109
141
  *
110
142
  * The `onChange` callback receives the raw numeric string without commas.
143
+ *
144
+ * `maxIntegerDigits` and `decimalPlaces` cap how much the user can enter. Edits that would
145
+ * exceed either limit are silently rejected, so state the limit in an adjacent `HelpMessage`.
111
146
  */
112
- export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign = 'right', style, enableClearButton, ...rest }, ref) => {
147
+ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign = 'right', style, enableClearButton, maxIntegerDigits, decimalPlaces, ...rest }, ref) => {
113
148
  const inputRef = useRef(null);
114
149
  const pendingCursorRef = useRef(null);
115
150
  const [cursorRestoreToken, requestCursorRestore] = useReducer((count) => count + 1, 0);
@@ -165,6 +200,22 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
165
200
  rawCursor = rawPos - 1;
166
201
  }
167
202
  }
203
+ // Reject an edit that breaks a limit, but only when it makes that measure worse.
204
+ // A value that arrives over the limit from the outside must stay editable back into range.
205
+ const exceedsDigitLimit = maxIntegerDigits !== undefined &&
206
+ countIntegerDigits(sanitized) > maxIntegerDigits &&
207
+ countIntegerDigits(sanitized) > countIntegerDigits(prevSanitized);
208
+ const exceedsDecimalLimit = decimalPlaces !== undefined &&
209
+ countDecimalPlaces(sanitized) > decimalPlaces &&
210
+ countDecimalPlaces(sanitized) > countDecimalPlaces(prevSanitized);
211
+ if (exceedsDigitLimit || exceedsDecimalLimit) {
212
+ // The display value does not change, so the cursor belongs before the rejected input.
213
+ const rawGained = Math.max(sanitized.length - prevSanitized.length, 0);
214
+ pendingCursorRef.current = mapRawCursorToDisplay(displayValue, Math.max(rawCursor - rawGained, 0));
215
+ // State is unchanged, so bump the token to make the layout effect restore the input.
216
+ requestCursorRestore();
217
+ return;
218
+ }
168
219
  const formatted = formatWithCommas(sanitized);
169
220
  let newCursor = mapRawCursorToDisplay(formatted, rawCursor);
170
221
  // Rejected characters keep the cursor before the ignored input.
@@ -179,7 +230,7 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
179
230
  requestCursorRestore();
180
231
  }
181
232
  onChange?.(sanitized);
182
- }, [displayValue, onChange]);
233
+ }, [displayValue, onChange, maxIntegerDigits, decimalPlaces]);
183
234
  return (_jsx(TextBox, { ref: mergeRefs(ref, inputRef), ...rest, className: cx('mfui-NumberBox__root', rest.className), enableClearButton: Boolean(enableClearButton) && displayValue !== '', style: { textAlign, ...style }, value: displayValue, onChange: handleChange }));
184
235
  });
185
236
  NumberBox.displayName = 'NumberBox';
@@ -21,4 +21,39 @@ export type NumberBoxProps = Omit<TextBoxProps, 'onChange' | 'prefixSlot' | 'suf
21
21
  * @default 'right'
22
22
  */
23
23
  textAlign?: 'left' | 'right';
24
+ /**
25
+ * The maximum number of digits (`0-9`) allowed in the integer part of the value, i.e.
26
+ * before the decimal point. The minus sign, the decimal point and the grouping commas
27
+ * are not counted.
28
+ *
29
+ * This limit is independent of `decimalPlaces`, so the two stack: `maxIntegerDigits={13}`
30
+ * combined with `decimalPlaces={2}` allows up to 13 integer digits and 2 decimal digits
31
+ * (15 digits total).
32
+ *
33
+ * An edit that would exceed the limit is silently rejected: the previous value and the
34
+ * cursor position are kept and `onChange` is not called. Because nothing is announced,
35
+ * state the limit up front in an adjacent `HelpMessage` referenced by `aria-describedby`
36
+ * (WCAG 2.2 3.3.2).
37
+ *
38
+ * The limit applies to typing and pasting only. A `value` or `defaultValue` that already
39
+ * exceeds it is displayed as is, and the user can still edit it back into range.
40
+ *
41
+ * @default undefined
42
+ */
43
+ maxIntegerDigits?: number;
44
+ /**
45
+ * The maximum number of digits allowed after the decimal point.
46
+ * `0` also rejects the decimal point itself, making the field integers-only.
47
+ *
48
+ * An edit that would exceed the limit is silently rejected: the previous value and the
49
+ * cursor position are kept and `onChange` is not called. Because nothing is announced,
50
+ * state the limit up front in an adjacent `HelpMessage` referenced by `aria-describedby`
51
+ * (WCAG 2.2 3.3.2).
52
+ *
53
+ * The limit applies to typing and pasting only. A `value` or `defaultValue` that already
54
+ * exceeds it is displayed as is, and the user can still edit it back into range.
55
+ *
56
+ * @default undefined
57
+ */
58
+ decimalPlaces?: number;
24
59
  };
@@ -8,7 +8,10 @@ import { cx } from '../../styled-system/css';
8
8
  * The general-purpose Pagination component.
9
9
  * This component switches the looks depends on the props: showItemsCount, showPagePicker and showItemsPerPage.
10
10
  */
11
- export function Pagination({ 'aria-label': ariaLabel, currentPageNumber, totalItems, itemsPerPage, unitLabel = '件', prevButtonProps, pageSelectBoxProps, nextButtonProps, itemsPerPageProps = {}, itemsPerPageSelectBoxProps, showItemsCount = false, showPagePicker = false, showItemsPerPage = false, alignment = 'left', pageSuffixLabel = 'ページ', onPageChange, ...props }) {
11
+ export function Pagination({ 'aria-label': ariaLabel, currentPageNumber, totalItems, itemsPerPage, unitLabel = '件', prevButtonProps, pageSelectBoxProps, nextButtonProps, itemsPerPageProps = {}, itemsPerPageSelectBoxProps, showItemsCount = false, showPagePicker = false,
12
+ // No default: `showItemsPerPage` is a required discriminant in both members of the
13
+ // props union, so a default here is unreachable.
14
+ showItemsPerPage, alignment = 'left', pageSuffixLabel = 'ページ', onPageChange, ...props }) {
12
15
  const { label = '1ページの表示数', options, onChange } = itemsPerPageProps;
13
16
  const totalPages = Math.ceil(totalItems / itemsPerPage);
14
17
  const classes = paginationSlotRecipe({ alignment });
@@ -119,8 +119,7 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
119
119
  if (isInShadowDOM) {
120
120
  // In Shadow DOM: check both shadow and document active elements
121
121
  isFocusStillWithinComponent =
122
- isElementWithinComponent(shadowActiveElement) ||
123
- isElementWithinComponent(documentActiveElement);
122
+ isElementWithinComponent(shadowActiveElement) || isElementWithinComponent(documentActiveElement);
124
123
  // Special case: If shadow activeElement is null, it means focus has left the shadow DOM.
125
124
  // We should trigger onBlur unless the document activeElement is still within our component
126
125
  // (which would be the case if focus moved to the shadow host or related elements)
@@ -28,5 +28,7 @@ export const RadioButtonCard = forwardRef(({ label, layout = 'vertical', renderD
28
28
  disableResponsive,
29
29
  isResponsive: Boolean(responsiveSlot.narrowViewport),
30
30
  });
31
- return (_jsxs("div", { className: cx(classes.root, 'mfui-RadioButtonCard__root', className), children: [_jsx(FocusIndicator, { disableNestedFocus: true, children: _jsxs(Typography, { variant: "label", className: cx(classes.inputWrapper, 'mfui-RadioButtonCard__inputWrapper'), as: "label", htmlFor: props.id, children: [_jsx(RadioButton, { ref: ref, ...props, className: cx(classes.input, 'mfui-RadioButtonCard__input') }), _jsx("span", { className: cx(classes.label, 'mfui-RadioButtonCard__label'), children: label })] }) }), responsiveSlot.narrowViewport && disableResponsive ? (_jsxs(_Fragment, { children: [_jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsWide, 'mfui-RadioButtonCard__details'), children: responsiveSlot.base(radioButtonState) }), _jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsNarrow, 'mfui-RadioButtonCard__details'), children: responsiveSlot.narrowViewport(radioButtonState) })] })) : responsiveSlot.narrowViewport ? (_jsxs(_Fragment, { children: [_jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsWide, 'mfui-RadioButtonCard__details'), children: responsiveSlot.base(radioButtonState) }), _jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsNarrow, 'mfui-RadioButtonCard__details'), children: responsiveSlot.narrowViewport(radioButtonState) })] })) : responsiveSlot.base ? (_jsx("div", { "data-mfui-content": "details", className: cx(classes.details, 'mfui-RadioButtonCard__details'), children: responsiveSlot.base(radioButtonState) })) : null] }));
31
+ return (_jsxs("div", { className: cx(classes.root, 'mfui-RadioButtonCard__root', className), children: [_jsx(FocusIndicator, { disableNestedFocus: true, children: _jsxs(Typography, { variant: "label", className: cx(classes.inputWrapper, 'mfui-RadioButtonCard__inputWrapper'), as: "label", htmlFor: props.id, children: [_jsx(RadioButton, { ref: ref, ...props, className: cx(classes.input, 'mfui-RadioButtonCard__input') }), _jsx("span", { className: cx(classes.label, 'mfui-RadioButtonCard__label'), children: label })] }) }), responsiveSlot.narrowViewport ? (_jsxs(_Fragment, { children: [_jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsWide, 'mfui-RadioButtonCard__details'), children: responsiveSlot.base(radioButtonState) }), _jsx("div", { "data-mfui-content": "details", className: cx(classes.details, classes.detailsNarrow, 'mfui-RadioButtonCard__details'), children: responsiveSlot.narrowViewport(radioButtonState) })] })) : (
32
+ /* For function-based renderDetailsSlot or ResponsiveSlot with only base, use single details element */
33
+ responsiveSlot.base && (_jsx("div", { "data-mfui-content": "details", className: cx(classes.details, 'mfui-RadioButtonCard__details'), children: responsiveSlot.base(radioButtonState) })))] }));
32
34
  });
@@ -353,22 +353,31 @@ export const SelectBox = forwardRef((props, ref) => {
353
353
  ]);
354
354
  // Render infinite scroll loading indicator
355
355
  const renderInfiniteScrollLoading = useCallback(() => isInfiniteScrollLoading && enableInfiniteScroll ? (_jsx("div", { className: cx(classes.infiniteScrollLoading, 'mfui-SelectBox__infiniteScrollLoading'), children: _jsx(ProgressIndicator, {}) }, "infinite-scroll-loading")) : null, [isInfiniteScrollLoading, enableInfiniteScroll, classes.infiniteScrollLoading]);
356
+ // Early returns rather than a four-level ternary: the branches are mutually exclusive
357
+ // and each returns a different shape (skeletons, virtualized rows, options, or the
358
+ // empty message).
359
+ const renderListContent = () => {
360
+ if (loading) {
361
+ return Array.from({ length: SKELETON_ITEM_COUNT }).map((_, index) => (_jsx("li", { className: cx(classes.skeletonItem, 'mfui-SelectBox__skeletonItem'), children: _jsx(Skeleton, {}) }, index)));
362
+ }
363
+ if (isVirtualized && virtualItems.length > 0) {
364
+ return renderVirtualizedItems();
365
+ }
366
+ if (filteredOptions.length > 0) {
367
+ return [
368
+ ...renderNonVirtualizedItems(),
369
+ !isVirtualized && renderInfiniteScrollLoading(),
370
+ !isVirtualized && renderInfiniteScrollError(),
371
+ ].filter(Boolean);
372
+ }
373
+ return [renderEmptyMessage(), !isVirtualized && renderInfiniteScrollError()].filter(Boolean);
374
+ };
356
375
  const optionsNode = (_jsxs("div", { ref: scrollWrapperRef, className: cx(classes.scrollWrapper, 'mfui-SelectBox__scrollWrapper'), onScroll: enableInfiniteScroll ? handleInfiniteScroll : undefined, children: [_jsx("ul", { role: "listbox", id: listBoxId, className: cx(classes.listBox, 'mfui-SelectBox__listBox'), tabIndex: -1, style: isVirtualized
357
376
  ? {
358
377
  height: `${String(totalSize)}px`,
359
378
  position: 'relative',
360
379
  }
361
- : undefined, children: loading
362
- ? Array.from({ length: SKELETON_ITEM_COUNT }).map((_, index) => (_jsx("li", { className: cx(classes.skeletonItem, 'mfui-SelectBox__skeletonItem'), children: _jsx(Skeleton, {}) }, index)))
363
- : isVirtualized && virtualItems.length > 0
364
- ? renderVirtualizedItems()
365
- : filteredOptions.length > 0
366
- ? [
367
- ...renderNonVirtualizedItems(),
368
- !isVirtualized && renderInfiniteScrollLoading(),
369
- !isVirtualized && renderInfiniteScrollError(),
370
- ].filter(Boolean)
371
- : [renderEmptyMessage(), !isVirtualized && renderInfiniteScrollError()].filter(Boolean) }), isVirtualized ? renderInfiniteScrollLoading() : null, isVirtualized ? renderInfiniteScrollError('div') : null, _jsx(OptionGroupLabelDescriptions, { groups: rendersHiddenGroupLabels ? optionGroupLabels : [] })] }));
380
+ : undefined, children: renderListContent() }), isVirtualized ? renderInfiniteScrollLoading() : null, isVirtualized ? renderInfiniteScrollError('div') : null, _jsx(OptionGroupLabelDescriptions, { groups: rendersHiddenGroupLabels ? optionGroupLabels : [] })] }));
372
381
  const handleClearValue = () => {
373
382
  setLocalSelectedOption(null);
374
383
  onChange?.(null);
@@ -36,7 +36,7 @@ export function getFlatOptions(options) {
36
36
  export function escapeAttributeSelector(value) {
37
37
  const stringValue = String(value);
38
38
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
39
- if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
39
+ if (typeof window !== 'undefined' && typeof window.CSS?.escape === 'function') {
40
40
  return window.CSS.escape(stringValue);
41
41
  }
42
42
  // Polyfill for CSS.escape
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useId, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
4
  import { Close, PaneExpansionToRight, PaneExpansionToLeft } from '@moneyforward/mfui-icons-react';
5
5
  import { cx } from '../../styled-system/css';
6
6
  import { Heading } from '../Heading';
@@ -8,6 +8,9 @@ import { IconButton } from '../IconButton';
8
8
  import { sidePaneSlotRecipe } from '../../styled-system/recipes';
9
9
  import { useSidePaneStateController } from './hooks/useSidePaneStateController';
10
10
  import { useFocusTrap } from '../utilities/dom/useFocusTrap';
11
+ import { getFocusableNodes } from '../utilities/dom/getFocusableNodes';
12
+ import { getDeepActiveElement } from '../utilities/dom/getDeepActiveElement';
13
+ import { containsAcrossShadowRoots } from '../utilities/dom/containsAcrossShadowRoots';
11
14
  import { compatibleForwardRef } from '../utilities/react/compatibleForwardRef';
12
15
  import { Portal, TargetDomNodeProvider, useAutomaticTargetDomNode } from '../Portal';
13
16
  import { FormFooter } from '../FormFooter';
@@ -21,7 +24,7 @@ import { FormFooter } from '../FormFooter';
21
24
  *
22
25
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
23
26
  */
24
- export const SidePane = compatibleForwardRef(({ open, title, 'aria-labelledby': ariaLabelledby, 'aria-label': ariaLabel, renderHeading, onClose, className, children, actionSlot, formFooterProps, position = 'right', enableModal = false, enableAutoUnmount = true, disableBackdropClose = false, closeButtonProps, targetDOMNode, insideProps, ...props }, ref) => {
27
+ export const SidePane = compatibleForwardRef(({ open, title, 'aria-labelledby': ariaLabelledby, 'aria-label': ariaLabel, renderHeading, onClose, className, children, actionSlot, formFooterProps, position = 'right', enableModal = false, enableAutoUnmount = true, disableBackdropClose = false, disableClose = false, closeButtonProps, targetDOMNode, insideProps, ...props }, ref) => {
25
28
  const classes = sidePaneSlotRecipe({ position, nonModal: !enableModal });
26
29
  const [insideElement, setInsideElement] = useState(null);
27
30
  const { localOpen, handleCloseSidePane, handleStopCloseSidePane, sidePaneElement, setSidePaneElement } = useSidePaneStateController({
@@ -31,21 +34,98 @@ export const SidePane = compatibleForwardRef(({ open, title, 'aria-labelledby':
31
34
  insideElement,
32
35
  });
33
36
  const handleBackdropClick = useCallback(() => {
34
- if (!disableBackdropClose) {
37
+ if (!disableClose && !disableBackdropClose) {
35
38
  handleCloseSidePane();
36
39
  }
37
- }, [disableBackdropClose, handleCloseSidePane]);
40
+ }, [disableClose, disableBackdropClose, handleCloseSidePane]);
38
41
  const handleKeyDown = useCallback((event) => {
39
42
  if (event.key === 'Escape') {
43
+ // The event is swallowed even while closing is disabled, so that ESC inside a nested
44
+ // side pane never reaches an outer one.
40
45
  event.stopPropagation();
41
46
  event.preventDefault();
42
- handleCloseSidePane();
47
+ if (!disableClose) {
48
+ handleCloseSidePane();
49
+ }
43
50
  }
44
51
  props.onKeyDown?.(event);
45
- }, [handleCloseSidePane, props]);
52
+ }, [disableClose, handleCloseSidePane, props]);
53
+ // Whether the close button currently holds focus, tracked so the recovery effect below can
54
+ // tell focus the browser kicked out from under the user apart from focus that legitimately
55
+ // sits outside a non-modal pane.
56
+ const closeButtonHasFocusRef = useRef(false);
57
+ const handleCloseButtonFocus = useCallback(() => {
58
+ closeButtonHasFocusRef.current = true;
59
+ }, []);
60
+ const handleCloseButtonBlur = useCallback((event) => {
61
+ // `event.currentTarget.disabled`, not the `disableClose` prop: React applies DOM properties
62
+ // before it refreshes the delegated-event props, so the blur the browser dispatches while
63
+ // this button is being disabled is handled with the previous render's closure, where
64
+ // `disableClose` was still false. The DOM property is already up to date at that point, so
65
+ // a blur caused by disabling keeps the flag set for the effect below to consume, while an
66
+ // ordinary blur clears it.
67
+ closeButtonHasFocusRef.current = event.currentTarget.disabled;
68
+ }, []);
69
+ // A focused element that becomes `disabled` is blurred by the browser, moving focus to
70
+ // <body> — outside the `role="dialog"` subtree that `aria-modal` and the focus trap depend
71
+ // on (see useSidePaneStateController). Pull focus back to the pane window.
72
+ // `preventScroll` is required for the same reason as the autofocus effect in that hook.
73
+ useEffect(() => {
74
+ if (!disableClose || !localOpen) {
75
+ // Closing is possible again, or the pane is gone: drop the tracked focus so that a later
76
+ // transition into `disableClose` can never consume a stale `true`. The close button is
77
+ // enabled on these runs, so its own focus/blur handlers keep the flag accurate.
78
+ closeButtonHasFocusRef.current = false;
79
+ return;
80
+ }
81
+ if (!insideElement)
82
+ return;
83
+ if (enableModal) {
84
+ // `document.activeElement` stops at the shadow host when the pane is portalled into a
85
+ // shadow root (via `targetDOMNode`), so resolve from `insideElement`'s own root instead
86
+ // and follow nested shadow roots down to the truly focused element.
87
+ const rootNode = insideElement.getRootNode();
88
+ const activeElement = getDeepActiveElement(rootNode instanceof ShadowRoot ? rootNode : insideElement.ownerDocument);
89
+ // `sidePaneElement` (the overlay root), not `insideElement`: nested overlays portal into
90
+ // the overlay root via the TargetDomNodeProvider below, so focus inside a nested Dialog,
91
+ // listbox or calendar is legitimately outside the pane window and must not be stolen.
92
+ // The containment test has to cross shadow boundaries, which `Node.contains()` does not:
93
+ // the lookup above pierces shadow roots downwards, so focus inside a web component
94
+ // rendered in the pane resolves to a node that no ancestor `contains()`.
95
+ if (containsAcrossShadowRoots(sidePaneElement, activeElement))
96
+ return;
97
+ insideElement.focus({ preventScroll: true });
98
+ return;
99
+ }
100
+ // Non-modal panes (e.g. `EmbeddedSidePane`) trap nothing and have no `aria-modal` boundary
101
+ // to keep focus within, so a blanket restore would steal focus that legitimately sits
102
+ // outside the pane. Only recover when the close button itself held focus as it was
103
+ // disabled. Tracked focus state rather than the blur event itself, because browsers are
104
+ // inconsistent about firing `blur` on the disabled-while-focused fixup (jsdom never does),
105
+ // and React dispatches it with the pre-disable props.
106
+ if (!closeButtonHasFocusRef.current)
107
+ return;
108
+ closeButtonHasFocusRef.current = false;
109
+ insideElement.focus({ preventScroll: true });
110
+ }, [disableClose, enableModal, localOpen, insideElement, sidePaneElement]);
111
+ const handlePressingTabOnNotFocusableElement = useCallback((event) => {
112
+ // When every control is disabled — `disableClose` together with a submitting FormFooter,
113
+ // for instance — the overlay holds no focusable node and the trap has nothing to wrap to.
114
+ // Keep focus on the pane window instead of letting Tab escape the `aria-modal` boundary.
115
+ // Measured over `sidePaneElement` (the overlay root), which is the same subtree the trap
116
+ // itself measures as its `currentTarget`, and the same boundary as the effect above: a
117
+ // nested Dialog, listbox or calendar portalled in there is still keyboard reachable, so
118
+ // Tab must be left alone rather than pulled back to this pane window.
119
+ // (`sidePaneElement` is the node this handler is bound to, so it is never null here.)
120
+ if (!insideElement || !sidePaneElement || getFocusableNodes(sidePaneElement).length > 0)
121
+ return;
122
+ event.preventDefault();
123
+ insideElement.focus({ preventScroll: true });
124
+ }, [insideElement, sidePaneElement]);
46
125
  // Get the focus trap handler
47
126
  const { handleOnKeyDown: focusTrapKeyHandler } = useFocusTrap({
48
127
  onKeyDown: handleKeyDown,
128
+ onPressingTabOnNotFocusableElement: handlePressingTabOnNotFocusableElement,
49
129
  });
50
130
  // Only use focus trap for modal SidePanes
51
131
  const handleOnKeyDown = enableModal ? focusTrapKeyHandler : handleKeyDown;
@@ -67,5 +147,5 @@ export const SidePane = compatibleForwardRef(({ open, title, 'aria-labelledby':
67
147
  return (_jsx(TargetDomNodeProvider, { targetDomNode: sidePaneElement, children: shouldRenderContent ? (_jsx(Portal, { targetDOMNode: targetElement, children: _jsxs("div", { ref: setSidePaneElement, ...props, className: cx(classes.root, 'mfui-SidePane__root', className), style: {
68
148
  ...props.style,
69
149
  ...(!enableAutoUnmount && !localOpen && { display: 'none' }),
70
- }, onKeyDown: handleOnKeyDown, children: [enableModal ? (_jsx("div", { "data-mfui-content": "backdrop", className: cx(classes.backdrop, 'mfui-SidePane__backdrop'), onClick: handleBackdropClick })) : null, _jsxs("div", { ref: setInsideElement, role: "dialog", "aria-modal": enableModal ? 'true' : undefined, "aria-labelledby": ariaLabelledby ?? (title ? titleId : undefined), "aria-label": ariaLabel, tabIndex: -1, "data-mfui-content": "inside", className: cx(classes.inside, 'mfui-SidePane__inside', insideProps?.className), onClick: handleStopCloseSidePane, children: [_jsxs("div", { className: cx(classes.header, 'mfui-SidePane__header'), children: [renderHeading ? (renderHeading({ headingNode })) : (_jsx("div", { className: cx(classes.title, 'mfui-SidePane__title'), children: headingNode })), _jsxs("div", { className: cx(classes.actionSlotWrapper, 'mfui-SidePane__actionSlotWrapper'), children: [!!actionSlot && (_jsx("div", { className: cx(classes.actionSlot, 'mfui-SidePane__actionSlot'), children: actionSlot })), _jsx("div", { className: cx(classes.closeButtonWrapper, 'mfui-SidePane__closeButtonWrapper'), children: _jsx(IconButton, { "aria-label": closeButtonProps?.['aria-label'] ?? '閉じる', onClick: handleCloseSidePane, children: closeButtonIcon }) })] })] }), _jsxs("div", { className: cx(classes.content, 'mfui-SidePane__content'), children: [_jsx("div", { className: cx(classes.contentBody, 'mfui-SidePane__contentBody'), children: children }), resolvedFormFooterPosition === 'stacking' ? (_jsx("div", { className: cx(classes.footer, 'mfui-SidePane__footer'), children: _jsx(FormFooter, { sectionPosition: "fill", ...formFooterProps, position: resolvedFormFooterPosition }) })) : null] }), formFooterProps && resolvedFormFooterPosition !== 'stacking' ? (_jsx("div", { className: cx(classes.footer, 'mfui-SidePane__footer'), children: _jsx(FormFooter, { sectionPosition: "fill", ...formFooterProps, position: resolvedFormFooterPosition }) })) : null] })] }) })) : null }));
150
+ }, onKeyDown: handleOnKeyDown, children: [enableModal ? (_jsx("div", { "data-mfui-content": "backdrop", className: cx(classes.backdrop, 'mfui-SidePane__backdrop'), onClick: handleBackdropClick })) : null, _jsxs("div", { ref: setInsideElement, role: "dialog", "aria-modal": enableModal ? 'true' : undefined, "aria-labelledby": ariaLabelledby ?? (title ? titleId : undefined), "aria-label": ariaLabel, tabIndex: -1, "data-mfui-content": "inside", className: cx(classes.inside, 'mfui-SidePane__inside', insideProps?.className), onClick: handleStopCloseSidePane, children: [_jsxs("div", { className: cx(classes.header, 'mfui-SidePane__header'), children: [renderHeading ? (renderHeading({ headingNode })) : (_jsx("div", { className: cx(classes.title, 'mfui-SidePane__title'), children: headingNode })), _jsxs("div", { className: cx(classes.actionSlotWrapper, 'mfui-SidePane__actionSlotWrapper'), children: [!!actionSlot && (_jsx("div", { className: cx(classes.actionSlot, 'mfui-SidePane__actionSlot'), children: actionSlot })), _jsx("div", { className: cx(classes.closeButtonWrapper, 'mfui-SidePane__closeButtonWrapper'), children: _jsx(IconButton, { "aria-label": closeButtonProps?.['aria-label'] ?? '閉じる', disabled: disableClose, onClick: handleCloseSidePane, onFocus: handleCloseButtonFocus, onBlur: handleCloseButtonBlur, children: closeButtonIcon }) })] })] }), _jsxs("div", { className: cx(classes.content, 'mfui-SidePane__content'), children: [_jsx("div", { className: cx(classes.contentBody, 'mfui-SidePane__contentBody'), children: children }), resolvedFormFooterPosition === 'stacking' ? (_jsx("div", { className: cx(classes.footer, 'mfui-SidePane__footer'), children: _jsx(FormFooter, { sectionPosition: "fill", ...formFooterProps, position: resolvedFormFooterPosition }) })) : null] }), formFooterProps && resolvedFormFooterPosition !== 'stacking' ? (_jsx("div", { className: cx(classes.footer, 'mfui-SidePane__footer'), children: _jsx(FormFooter, { sectionPosition: "fill", ...formFooterProps, position: resolvedFormFooterPosition }) })) : null] })] }) })) : null }));
71
151
  });
@@ -134,10 +134,49 @@ export type SidePaneBaseProps = {
134
134
  /**
135
135
  * Disables closing the side pane when clicking on the backdrop (modal mode only).
136
136
  * When true, the side pane can only be closed via buttons or the close button.
137
+ * The ESC key still closes the side pane. Use `disableClose` to suppress every
138
+ * closing trigger at once.
137
139
  *
138
140
  * @default false
139
141
  */
140
142
  disableBackdropClose?: boolean;
143
+ /**
144
+ * Disables every trigger that closes the side pane: the close button (rendered in the
145
+ * disabled style), the ESC key, and the backdrop click. Use it while an operation the
146
+ * user cannot cancel is in flight, such as a form submission.
147
+ *
148
+ * This does not block the `open` prop, so the side pane can always be closed programmatically.
149
+ *
150
+ * A modal side pane cannot be dismissed at all while this is true, so use it only for short,
151
+ * bounded operations, clear it on both success and failure, and show visible progress inside
152
+ * the pane so the user understands why closing is unavailable.
153
+ *
154
+ * @default false
155
+ *
156
+ * @example
157
+ * ```tsx
158
+ * <SidePane
159
+ * open={isOpen}
160
+ * enableModal
161
+ * title="Edit profile"
162
+ * disableClose={isSubmitting}
163
+ * onClose={handleClose}
164
+ * formFooterProps={{
165
+ * actionsSlot: (
166
+ * <>
167
+ * <Button disabled={isSubmitting} onClick={handleClose}>
168
+ * キャンセル
169
+ * </Button>
170
+ * <Button priority="primary" loading={isSubmitting} onClick={handleSubmit}>
171
+ * 保存
172
+ * </Button>
173
+ * </>
174
+ * ),
175
+ * }}
176
+ * />
177
+ * ```
178
+ */
179
+ disableClose?: boolean;
141
180
  /**
142
181
  * Enable auto unmounting of the side pane content when it's not displayed.
143
182
  * When true, the side pane content will return null instead of hiding via CSS.
@@ -1,5 +1,14 @@
1
1
  import { type SubNavigationProps } from './SubNavigation.types';
2
2
  /**
3
3
  * This component is for the navigation links under the main navigation.
4
+ *
5
+ * The root element is a `<nav>` landmark. Name it with `aria-label` or `aria-labelledby` when the
6
+ * page has other navigation landmarks. No name is applied by default because the right name
7
+ * depends on what the navigation is for on each page.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * <SubNavigation navigationItems={items} aria-label="設定" />
12
+ * ```
4
13
  */
5
- export declare function SubNavigation({ orientation, navigationItems, customLinkComponent, renderNavigationLabel, headerSlot, className, }: SubNavigationProps): import("react/jsx-runtime").JSX.Element;
14
+ export declare const SubNavigation: import("react").ForwardRefExoticComponent<SubNavigationProps & import("react").RefAttributes<HTMLElement>>;
@@ -1,17 +1,28 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DisclosureBasicCollapsed, Lock } from '@moneyforward/mfui-icons-react';
3
+ import { forwardRef } from 'react';
3
4
  import { cx } from '../../styled-system/css';
4
5
  import { subNavigationSlotRecipe } from '../../styled-system/recipes';
5
6
  import { FocusIndicator } from '../FocusIndicator';
6
7
  import { Typography } from '../Typography';
7
8
  /**
8
9
  * This component is for the navigation links under the main navigation.
10
+ *
11
+ * The root element is a `<nav>` landmark. Name it with `aria-label` or `aria-labelledby` when the
12
+ * page has other navigation landmarks. No name is applied by default because the right name
13
+ * depends on what the navigation is for on each page.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <SubNavigation navigationItems={items} aria-label="設定" />
18
+ * ```
9
19
  */
10
- export function SubNavigation({ orientation = 'vertical', navigationItems, customLinkComponent, renderNavigationLabel, headerSlot, className, }) {
20
+ export const SubNavigation = forwardRef(({ orientation = 'vertical', navigationItems, customLinkComponent, renderNavigationLabel, headerSlot, className, ...rest }, ref) => {
11
21
  const hasLabelIcon = navigationItems.some(({ labelIcon }) => !!labelIcon);
12
22
  const classes = subNavigationSlotRecipe({ orientation, hasLabelIcon });
13
- return (_jsxs("nav", { className: cx(classes.root, 'mfui-SubNavigation__root', className), children: [headerSlot ? (_jsx("div", { className: cx(classes.subNavigationHeader, 'mfui-SubNavigation__subNavigationHeader'), children: headerSlot })) : null, _jsx("ul", { className: cx(classes.list, 'mfui-SubNavigation__list'), children: navigationItems.map((navigationItem, index) => (_jsx("li", { className: cx(classes.listItem, 'mfui-SubNavigation__listItem'), children: renderNavigationItem(navigationItem, customLinkComponent, classes, 0, renderNavigationLabel) }, index))) })] }));
14
- }
23
+ return (_jsxs("nav", { ...rest, ref: ref, className: cx(classes.root, 'mfui-SubNavigation__root', className), children: [headerSlot ? (_jsx("div", { className: cx(classes.subNavigationHeader, 'mfui-SubNavigation__subNavigationHeader'), children: headerSlot })) : null, _jsx("ul", { className: cx(classes.list, 'mfui-SubNavigation__list'), children: navigationItems.map((navigationItem, index) => (_jsx("li", { className: cx(classes.listItem, 'mfui-SubNavigation__listItem'), children: renderNavigationItem(navigationItem, customLinkComponent, classes, 0, renderNavigationLabel) }, index))) })] }));
24
+ });
25
+ SubNavigation.displayName = 'SubNavigation';
15
26
  /**
16
27
  * Render a navigation item based on the following conditions:
17
28
  * - a link without children
@@ -33,6 +33,32 @@ type HorizontalSubNavigationProps = BaseSubNavigationProps & {
33
33
  * Common properties for both vertical and horizontal SubNavigation.
34
34
  */
35
35
  type BaseSubNavigationProps = {
36
+ /**
37
+ * The accessible name of the `<nav>` landmark.
38
+ *
39
+ * Set this when the page has other navigation landmarks (e.g. `MainNavigation`, `Breadcrumbs`).
40
+ * Describe this page's navigation (e.g. `"設定"`). Avoid including the role name;
41
+ * assistive technology already announces it.
42
+ */
43
+ 'aria-label'?: string;
44
+ /**
45
+ * The ID of the element that names the `<nav>` landmark.
46
+ * Use this instead of `aria-label` when a visible heading already names the navigation.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * function SettingsNavigation() {
51
+ * const headingId = useId();
52
+ * return (
53
+ * <>
54
+ * <h2 id={headingId}>設定</h2>
55
+ * <SubNavigation navigationItems={items} aria-labelledby={headingId} />
56
+ * </>
57
+ * );
58
+ * }
59
+ * ```
60
+ */
61
+ 'aria-labelledby'?: string;
36
62
  /**
37
63
  * The component to use as the link element instead of the general anchor element `<a>`.
38
64
  * The custom component must extend the HTMLAnchorElement interface.
@@ -109,7 +135,7 @@ type BaseSubNavigationProps = {
109
135
  * For overriding styles if you need.
110
136
  */
111
137
  className?: string;
112
- };
138
+ } & Omit<ComponentPropsWithoutRef<'nav'>, 'children' | 'className' | 'aria-label' | 'aria-labelledby'>;
113
139
  /**
114
140
  * The union that discriminates whether each navigation item has children.
115
141
  */
@@ -44,14 +44,14 @@ function areTabListsEqual(a, b) {
44
44
  });
45
45
  }
46
46
  /**
47
- * Sorts the entries in place so they follow the order the tabs appear in the document.
47
+ * Returns the entries ordered by the position their elements appear in the document.
48
48
  * Entries whose element is not in the document yet keep their relative order, because
49
- * `Array#sort` is stable and the comparator reports them as equal.
49
+ * `Array#toSorted` is stable and the comparator reports them as equal.
50
50
  *
51
- * @param entries - The registered entries, sorted in place.
51
+ * @param entries - The registered entries to order.
52
52
  */
53
53
  function sortByDocumentPosition(entries) {
54
- entries.sort((a, b) => {
54
+ return entries.toSorted((a, b) => {
55
55
  const aElement = a.elementRef.current;
56
56
  const bElement = b.elementRef.current;
57
57
  if (!aElement?.isConnected || !bElement?.isConnected)
@@ -81,7 +81,7 @@ export function useTabRegistry() {
81
81
  const entriesRef = useRef([]);
82
82
  const { registerTab, updateTab, unregisterTab } = useMemo(() => {
83
83
  const publish = () => {
84
- sortByDocumentPosition(entriesRef.current);
84
+ entriesRef.current = sortByDocumentPosition(entriesRef.current);
85
85
  const nextTabs = entriesRef.current.map((entry) => entry.props);
86
86
  setTabs((previousTabs) => (areTabListsEqual(previousTabs, nextTabs) ? previousTabs : nextTabs));
87
87
  };
@@ -1,2 +1,2 @@
1
1
  export * from './ToggleSwitch';
2
- export * from './ToggleSwitch.types';
2
+ export type * from './ToggleSwitch.types';
@@ -1,2 +1 @@
1
1
  export * from './ToggleSwitch';
2
- export * from './ToggleSwitch.types';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Returns whether `node` is `ancestor` itself or a descendant of it, crossing shadow boundaries.
3
+ *
4
+ * `Node.contains()` walks the node tree only, so an element inside a shadow root is *not*
5
+ * reported as contained by the shadow host or by any of the host's ancestors. This function
6
+ * follows the chain of shadow hosts upwards, so focus inside a web component rendered within
7
+ * `ancestor` still counts as being inside it.
8
+ *
9
+ * Pair it with `getDeepActiveElement`, which pierces shadow roots downwards: a shadow-piercing
10
+ * lookup compared with the non-piercing `Node.contains()` reports the deepest focused element as
11
+ * outside every ancestor it actually lives in.
12
+ *
13
+ * @param ancestor - The node expected to contain `node`. A nullish ancestor contains nothing.
14
+ *
15
+ * @param node - The node to look for. A nullish node is never contained.
16
+ *
17
+ * @returns `true` when `node` is inside `ancestor`, across any number of shadow boundaries.
18
+ *
19
+ * @see https://dom.spec.whatwg.org/#dom-node-contains
20
+ */
21
+ export declare const containsAcrossShadowRoots: (ancestor: Node | null | undefined, node: Node | null | undefined) => boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Returns whether `node` is `ancestor` itself or a descendant of it, crossing shadow boundaries.
3
+ *
4
+ * `Node.contains()` walks the node tree only, so an element inside a shadow root is *not*
5
+ * reported as contained by the shadow host or by any of the host's ancestors. This function
6
+ * follows the chain of shadow hosts upwards, so focus inside a web component rendered within
7
+ * `ancestor` still counts as being inside it.
8
+ *
9
+ * Pair it with `getDeepActiveElement`, which pierces shadow roots downwards: a shadow-piercing
10
+ * lookup compared with the non-piercing `Node.contains()` reports the deepest focused element as
11
+ * outside every ancestor it actually lives in.
12
+ *
13
+ * @param ancestor - The node expected to contain `node`. A nullish ancestor contains nothing.
14
+ *
15
+ * @param node - The node to look for. A nullish node is never contained.
16
+ *
17
+ * @returns `true` when `node` is inside `ancestor`, across any number of shadow boundaries.
18
+ *
19
+ * @see https://dom.spec.whatwg.org/#dom-node-contains
20
+ */
21
+ export const containsAcrossShadowRoots = (ancestor, node) => {
22
+ if (!ancestor || !node)
23
+ return false;
24
+ let current = node;
25
+ while (current) {
26
+ if (ancestor.contains(current))
27
+ return true;
28
+ const rootNode = current.getRootNode();
29
+ // Only a ShadowRoot has a host to continue from; a Document ends the walk.
30
+ current = rootNode instanceof ShadowRoot ? rootNode.host : null;
31
+ }
32
+ return false;
33
+ };
@@ -18,7 +18,7 @@ export function useFocusTrap({ onKeyDown, onPressingTabOnNotFocusableElement } =
18
18
  const wrapper = event.currentTarget;
19
19
  const focusingElement = wrapper.ownerDocument.activeElement;
20
20
  const focusableNodes = getFocusableNodes(wrapper);
21
- const firstFocusableNode = focusableNodes[0];
21
+ const [firstFocusableNode] = focusableNodes;
22
22
  const lastFocusableNode = focusableNodes.at(-1);
23
23
  const focusingOnFirstNode = focusingElement === firstFocusableNode;
24
24
  const focusingOnLastNode = focusingElement === lastFocusableNode;