@moneyforward/mfui-components 4.3.0 → 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.
- package/dist/src/Accordion/Accordion.d.ts +21 -1
- package/dist/src/Accordion/Accordion.js +28 -4
- package/dist/src/Accordion/Accordion.types.d.ts +21 -0
- package/dist/src/Breadcrumbs/Breadcrumbs.js +1 -1
- package/dist/src/CheckboxCard/CheckboxCard.js +3 -1
- package/dist/src/DataTable/DataTable.js +9 -2
- package/dist/src/DataTable/DataTable.types.d.ts +15 -0
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +3 -1
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +19 -1
- package/dist/src/DataTable/DataTableRow/DataTableRow.js +4 -1
- package/dist/src/DataTable/DataTableRow/DataTableRow.types.d.ts +23 -1
- package/dist/src/DateTimeSelection/DatePicker/DatePickerDateCell/index.d.ts +1 -1
- package/dist/src/DateTimeSelection/DatePicker/DatePickerDateCell/index.js +0 -1
- package/dist/src/DateTimeSelection/shared/CalendarGrid/CalendarGrid.js +1 -3
- package/dist/src/DateTimeSelection/shared/CalendarGrid/utilities/computeDataForStyling.js +2 -4
- package/dist/src/Dialog/Dialog.d.ts +1 -1
- package/dist/src/Dialog/Dialog.types.d.ts +1 -1
- package/dist/src/FileDropZone/FileDropZone.d.ts +5 -1
- package/dist/src/FileDropZone/FileDropZone.js +26 -6
- package/dist/src/FileDropZone/FileDropZone.types.d.ts +10 -0
- package/dist/src/MainNavigation/BaseMainNavigation.d.ts +1 -1
- package/dist/src/MainNavigation/BaseMainNavigation.js +2 -2
- package/dist/src/MainNavigation/MainNavigation.js +2 -1
- package/dist/src/MainNavigation/MainNavigation.types.d.ts +9 -0
- package/dist/src/MainNavigation/NarrowViewportMainNavigation.js +2 -2
- package/dist/src/MultipleSelectBox/MultipleSelectBox.js +27 -18
- package/dist/src/MultipleSelectBox/hooks/useInitialFocusOnOptionPanelOpen.js +1 -1
- package/dist/src/NavigationList/NavigationListItem/NavigationListItem.d.ts +3 -0
- package/dist/src/NavigationList/NavigationListItem/NavigationListItem.js +6 -2
- package/dist/src/NavigationList/NavigationListItem/NavigationListItem.types.d.ts +2 -1
- package/dist/src/NumberBox/NumberBox.d.ts +5 -0
- package/dist/src/NumberBox/NumberBox.js +55 -3
- package/dist/src/NumberBox/NumberBox.types.d.ts +35 -0
- package/dist/src/Pagination/Pagination.js +4 -1
- package/dist/src/Popover/Popover.js +3 -3
- package/dist/src/RadioButtonCard/RadioButtonCard.js +3 -1
- package/dist/src/SelectBox/SelectBox.js +20 -11
- package/dist/src/SelectBox/utils/getFlatOptions.js +1 -1
- package/dist/src/SidePane/SidePane.js +87 -7
- package/dist/src/SidePane/SidePane.types.d.ts +39 -0
- package/dist/src/SidePane/hooks/useSidePaneStateController.js +3 -5
- package/dist/src/SplitView/utils/parseSize.d.ts +1 -1
- package/dist/src/SplitView/utils/parseSize.js +4 -2
- package/dist/src/SubNavigation/SubNavigation.d.ts +10 -1
- package/dist/src/SubNavigation/SubNavigation.js +14 -3
- package/dist/src/SubNavigation/SubNavigation.types.d.ts +27 -1
- package/dist/src/Tabs/TabContext.d.ts +3 -1
- package/dist/src/Tabs/TabContext.js +1 -0
- package/dist/src/Tabs/TabItem/TabItem.js +14 -3
- package/dist/src/Tabs/Tabs.js +5 -21
- package/dist/src/Tabs/hooks/useTabRegistry.d.ts +24 -0
- package/dist/src/Tabs/hooks/useTabRegistry.js +130 -0
- package/dist/src/ToggleSwitch/index.d.ts +1 -1
- package/dist/src/ToggleSwitch/index.js +0 -1
- package/dist/src/Tooltip/hooks/useOpenStateDelayed.js +2 -2
- package/dist/src/utilities/dom/containsAcrossShadowRoots.d.ts +21 -0
- package/dist/src/utilities/dom/containsAcrossShadowRoots.js +33 -0
- package/dist/src/utilities/dom/setInputFiles.d.ts +21 -0
- package/dist/src/utilities/dom/setInputFiles.js +41 -0
- package/dist/src/utilities/dom/toFileList.d.ts +20 -0
- package/dist/src/utilities/dom/toFileList.js +26 -0
- package/dist/src/utilities/dom/useFixedColumns.js +4 -2
- package/dist/src/utilities/dom/useFocusTrap.js +1 -1
- package/dist/src/utilities/dom/useOnClickOutside.js +3 -1
- package/dist/src/utilities/dom/useTableEdgeCellMarkers.d.ts +56 -0
- package/dist/src/utilities/dom/useTableEdgeCellMarkers.js +223 -0
- package/dist/styled-system/recipes/data-table-cell-slot-recipe.js +4 -2
- package/dist/styled-system/types/composition.d.ts +1 -1
- package/dist/styles.css +20 -4
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +3 -3
- /package/dist/src/{MultipleSelectBox/utils → utilities/dom}/getDeepActiveElement.d.ts +0 -0
- /package/dist/src/{MultipleSelectBox/utils → utilities/dom}/getDeepActiveElement.js +0 -0
|
@@ -339,29 +339,38 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
|
|
|
339
339
|
`${String(localSelectedValuesSet.size)}件選択中`) })) : null;
|
|
340
340
|
const applyControlsNode = enableApplyControls ? (_jsxs(HStack, { gap: "horizontal.0-1of2", children: [_jsx(Button, { ref: cancelButtonRef, size: "small", priority: "secondary", onClick: handleCancelButtonClick, children: cancelButtonProps?.label ?? 'キャンセル' }), _jsx(Button, { ref: applyButtonRef, size: "small", priority: "primary", onClick: handleApplyButtonClick, children: applyButtonProps?.label ?? '適用' })] })) : null;
|
|
341
341
|
const resolvedNotFoundMessage = typeof notFoundMessage === 'function' ? notFoundMessage(searchText.trim()) : notFoundMessage;
|
|
342
|
+
// Early returns rather than a four-level ternary: the branches are mutually exclusive
|
|
343
|
+
// and each returns a different shape (skeletons, virtualized rows, options, or the
|
|
344
|
+
// empty message).
|
|
345
|
+
const renderListContent = () => {
|
|
346
|
+
if (loading) {
|
|
347
|
+
return Array.from({ length: SKELETON_ITEM_COUNT }).map((_, index) => (_jsx("li", { className: cx(classes.skeletonItem, 'mfui-MultipleSelectBox__skeletonItem'), children: _jsx(Skeleton, {}) }, index)));
|
|
348
|
+
}
|
|
349
|
+
if (isVirtualized && virtualItems.length > 0) {
|
|
350
|
+
return renderVirtualizedItems();
|
|
351
|
+
}
|
|
352
|
+
if (filteredOptions.length > 0) {
|
|
353
|
+
return [
|
|
354
|
+
...renderNonVirtualizedItems(),
|
|
355
|
+
!isVirtualized && renderInfiniteScrollLoading(),
|
|
356
|
+
!isVirtualized && renderInfiniteScrollError(),
|
|
357
|
+
].filter(Boolean);
|
|
358
|
+
}
|
|
359
|
+
return [
|
|
360
|
+
_jsx("li", { className: cx(classes.emptyMessage, 'mfui-MultipleSelectBox__emptyMessage'), children: _jsx(Typography, { variant: "body", children: enableSearchOptions && searchText && resolvedNotFoundMessage
|
|
361
|
+
? resolvedNotFoundMessage
|
|
362
|
+
: options.length > 0
|
|
363
|
+
? resolvedNotFoundMessage
|
|
364
|
+
: emptyMessage }) }, "empty"),
|
|
365
|
+
!isVirtualized && renderInfiniteScrollError(),
|
|
366
|
+
].filter(Boolean);
|
|
367
|
+
};
|
|
342
368
|
const optionsNode = (_jsxs("div", { ref: scrollWrapperRef, className: cx(classes.scrollWrapper, 'mfui-MultipleSelectBox__scrollWrapper'), onScroll: enableInfiniteScroll ? handleInfiniteScroll : undefined, children: [_jsx("ul", { ref: listBoxRef, role: "listbox", id: listBoxId, className: cx(classes.listBox, 'mfui-MultipleSelectBox__listBox'), tabIndex: -1, style: isVirtualized && totalSize > 0
|
|
343
369
|
? {
|
|
344
370
|
height: `${String(totalSize)}px`,
|
|
345
371
|
position: 'relative',
|
|
346
372
|
}
|
|
347
|
-
: undefined, children:
|
|
348
|
-
? Array.from({ length: SKELETON_ITEM_COUNT }).map((_, index) => (_jsx("li", { className: cx(classes.skeletonItem, 'mfui-MultipleSelectBox__skeletonItem'), children: _jsx(Skeleton, {}) }, index)))
|
|
349
|
-
: isVirtualized && virtualItems.length > 0
|
|
350
|
-
? renderVirtualizedItems()
|
|
351
|
-
: filteredOptions.length > 0
|
|
352
|
-
? [
|
|
353
|
-
...renderNonVirtualizedItems(),
|
|
354
|
-
!isVirtualized && renderInfiniteScrollLoading(),
|
|
355
|
-
!isVirtualized && renderInfiniteScrollError(),
|
|
356
|
-
].filter(Boolean)
|
|
357
|
-
: [
|
|
358
|
-
_jsx("li", { className: cx(classes.emptyMessage, 'mfui-MultipleSelectBox__emptyMessage'), children: _jsx(Typography, { variant: "body", children: enableSearchOptions && searchText && resolvedNotFoundMessage
|
|
359
|
-
? resolvedNotFoundMessage
|
|
360
|
-
: options.length > 0
|
|
361
|
-
? resolvedNotFoundMessage
|
|
362
|
-
: emptyMessage }) }, "empty"),
|
|
363
|
-
!isVirtualized && renderInfiniteScrollError(),
|
|
364
|
-
].filter(Boolean) }), isVirtualized ? renderInfiniteScrollLoading() : null, isVirtualized ? renderInfiniteScrollError('div') : null, _jsx(OptionGroupLabelDescriptions, { groups: rendersHiddenGroupLabels ? optionGroupLabels : [] })] }));
|
|
373
|
+
: undefined, children: renderListContent() }), isVirtualized ? renderInfiniteScrollLoading() : null, isVirtualized ? renderInfiniteScrollError('div') : null, _jsx(OptionGroupLabelDescriptions, { groups: rendersHiddenGroupLabels ? optionGroupLabels : [] })] }));
|
|
365
374
|
const createOptionAreaNode = isCreateOptionVisible ? (_jsxs("div", { className: cx(classes.createOptionArea, 'mfui-MultipleSelectBox__createOptionArea'), children: [_jsxs("button", { "data-mfui-content": "create-new-option", type: "button", className: cx(classes.createOptionButton, 'mfui-MultipleSelectBox__createOptionButton'), disabled: isCreating, onClick: handleCreateOption, children: [_jsx(Add, { "aria-hidden": true }), createOptionProps?.renderLabel?.(searchText.trim()) ?? (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "strongBody", children: searchText.trim() }), _jsx(Typography, { variant: "body", children: "\u3092\u8FFD\u52A0" })] }))] }), createError ? (_jsx("div", { className: cx(classes.createOptionErrorMessage, 'mfui-MultipleSelectBox__createOptionErrorMessage'), children: _jsx(HelpMessage, { messageType: "error", children: createOptionProps?.renderErrorMessage?.(searchText.trim()) ??
|
|
366
375
|
`${searchText.trim()}を追加できませんでした。` }) })) : null] })) : null;
|
|
367
376
|
return (_jsx(Popover, { renderTrigger: ({ setTriggerRef, togglePopover, handleTriggerKeyDown, handleTriggerBlur }) => (_jsx(MultipleSelectBoxTrigger, { ref: triggerRef, wrapperRef: setTriggerRef, selectedOptions: localSelectedOptions, id: id, disabled: disabled, triggerProps: triggerProps, triggerWrapperProps: triggerWrapperProps, name: name,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
|
-
import { getDeepActiveElement } from '
|
|
2
|
+
import { getDeepActiveElement } from '../../utilities/dom/getDeepActiveElement';
|
|
3
3
|
import { isSelectableOption } from '../utils/isSelectableOption';
|
|
4
4
|
/**
|
|
5
5
|
* Control the focus when the option panel is opened.
|
|
@@ -6,5 +6,8 @@ import { type NavigationListItemProps } from './NavigationListItem.types';
|
|
|
6
6
|
* If the href prop is provided, the item will be rendered as an anchor element.
|
|
7
7
|
* If the onClick prop is provided (without href), it will be rendered as a button element.
|
|
8
8
|
* Otherwise, it will be rendered as a div element and not interactive.
|
|
9
|
+
*
|
|
10
|
+
* The trailing navigation indicator is displayed whenever the item can be activated,
|
|
11
|
+
* that is when either the href or the onClick prop is provided.
|
|
9
12
|
*/
|
|
10
13
|
export declare const NavigationListItem: import("react").ForwardRefExoticComponent<NavigationListItemProps & import("react").RefAttributes<HTMLLIElement>>;
|
|
@@ -16,6 +16,9 @@ const ARIA_DISABLED_PROPS = { 'aria-disabled': true, 'data-mfui-disabled': '' };
|
|
|
16
16
|
* If the href prop is provided, the item will be rendered as an anchor element.
|
|
17
17
|
* If the onClick prop is provided (without href), it will be rendered as a button element.
|
|
18
18
|
* Otherwise, it will be rendered as a div element and not interactive.
|
|
19
|
+
*
|
|
20
|
+
* The trailing navigation indicator is displayed whenever the item can be activated,
|
|
21
|
+
* that is when either the href or the onClick prop is provided.
|
|
19
22
|
*/
|
|
20
23
|
export const NavigationListItem = forwardRef(({ title, icon, description, leadingContent, href, target, customLinkComponent, onClick, disabled, className, ...rest }, ref) => {
|
|
21
24
|
const classes = navigationListItemSlotRecipe();
|
|
@@ -26,7 +29,8 @@ export const NavigationListItem = forwardRef(({ title, icon, description, leadin
|
|
|
26
29
|
return 'button';
|
|
27
30
|
return 'div';
|
|
28
31
|
}, [href, customLinkComponent, onClick]);
|
|
29
|
-
const
|
|
32
|
+
const isActivatable = href !== undefined || onClick !== undefined;
|
|
33
|
+
const isInteractive = isActivatable && !disabled;
|
|
30
34
|
const resolvedHref = disabled ? undefined : href;
|
|
31
35
|
const disabledProps = useMemo(() => {
|
|
32
36
|
if (!disabled)
|
|
@@ -35,6 +39,6 @@ export const NavigationListItem = forwardRef(({ title, icon, description, leadin
|
|
|
35
39
|
return BUTTON_DISABLED_PROPS;
|
|
36
40
|
return ARIA_DISABLED_PROPS;
|
|
37
41
|
}, [disabled, onClick, href]);
|
|
38
|
-
const innerElement = (_jsx(FocusIndicator, { hideWhiteBorder: true, position: "inside", children: _jsxs(Tag, { ...rest, href: resolvedHref, target: target, type: href === undefined && onClick !== undefined ? 'button' : undefined, "data-mfui-focusable": isInteractive, className: cx(classes.root, 'mfui-NavigationListItem__root', className), onClick: disabled ? undefined : onClick, ...disabledProps, children: [leadingContent != null ? (_jsx("span", { className: cx(classes.leading, 'mfui-NavigationListItem__leading'), children: leadingContent })) : null, _jsxs("span", { className: cx(classes.content, 'mfui-NavigationListItem__content'), children: [_jsxs("span", { className: cx(classes.title, 'mfui-NavigationListItem__title'), children: [icon != null ? (_jsx("span", { className: cx(classes.titleIcon, 'mfui-NavigationListItem__titleIcon'), children: icon })) : null, _jsx(Typography, { variant: "contentHeading", children: title })] }), description != null ? _jsx(Typography, { variant: "body", children: description }) : null] }),
|
|
42
|
+
const innerElement = (_jsx(FocusIndicator, { hideWhiteBorder: true, position: "inside", children: _jsxs(Tag, { ...rest, href: resolvedHref, target: target, type: href === undefined && onClick !== undefined ? 'button' : undefined, "data-mfui-focusable": isInteractive, className: cx(classes.root, 'mfui-NavigationListItem__root', className), onClick: disabled ? undefined : onClick, ...disabledProps, children: [leadingContent != null ? (_jsx("span", { className: cx(classes.leading, 'mfui-NavigationListItem__leading'), children: leadingContent })) : null, _jsxs("span", { className: cx(classes.content, 'mfui-NavigationListItem__content'), children: [_jsxs("span", { className: cx(classes.title, 'mfui-NavigationListItem__title'), children: [icon != null ? (_jsx("span", { className: cx(classes.titleIcon, 'mfui-NavigationListItem__titleIcon'), children: icon })) : null, _jsx(Typography, { variant: "contentHeading", children: title })] }), description != null ? _jsx(Typography, { variant: "body", children: description }) : null] }), isActivatable ? (_jsx("span", { className: cx(classes.trailing, 'mfui-NavigationListItem__trailing'), children: _jsx(NavigationToDetail, { "aria-hidden": true }) })) : null] }) }));
|
|
39
43
|
return _jsx("li", { ref: ref, children: innerElement });
|
|
40
44
|
});
|
|
@@ -25,7 +25,8 @@ type ButtonProps = {
|
|
|
25
25
|
customLinkComponent?: undefined;
|
|
26
26
|
/**
|
|
27
27
|
* The click handler for the navigation item.
|
|
28
|
-
* When provided without href, the item is rendered as a button element
|
|
28
|
+
* When provided without href, the item is rendered as a button element
|
|
29
|
+
* and displays the same trailing navigation indicator as an anchor item.
|
|
29
30
|
*/
|
|
30
31
|
onClick: ComponentPropsWithoutRef<'button'>['onClick'];
|
|
31
32
|
/**
|
|
@@ -5,8 +5,13 @@ import React from 'react';
|
|
|
5
5
|
* and ignores invalid characters.
|
|
6
6
|
*
|
|
7
7
|
* The `onChange` callback receives the raw numeric string without commas.
|
|
8
|
+
*
|
|
9
|
+
* `maxIntegerDigits` and `decimalPlaces` cap how much the user can enter. Edits that would
|
|
10
|
+
* exceed either limit are silently rejected, so state the limit in an adjacent `HelpMessage`.
|
|
8
11
|
*/
|
|
9
12
|
export declare const NumberBox: React.ForwardRefExoticComponent<Omit<import("..").TextBoxProps, "onChange" | "prefixSlot" | "suffixSlot"> & {
|
|
10
13
|
onChange?: (value: string) => void;
|
|
11
14
|
textAlign?: 'left' | 'right';
|
|
15
|
+
maxIntegerDigits?: number;
|
|
16
|
+
decimalPlaces?: number;
|
|
12
17
|
} & React.RefAttributes<HTMLInputElement>>;
|
|
@@ -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);
|
|
@@ -117,7 +152,8 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
|
|
|
117
152
|
// Sync with controlled value
|
|
118
153
|
useEffect(() => {
|
|
119
154
|
if (value !== undefined) {
|
|
120
|
-
|
|
155
|
+
const sanitized = sanitizeInput(String(value));
|
|
156
|
+
setDisplayValue(formatWithCommas(sanitized));
|
|
121
157
|
}
|
|
122
158
|
}, [value]);
|
|
123
159
|
// Restore cursor position after display value update
|
|
@@ -164,6 +200,22 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
|
|
|
164
200
|
rawCursor = rawPos - 1;
|
|
165
201
|
}
|
|
166
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
|
+
}
|
|
167
219
|
const formatted = formatWithCommas(sanitized);
|
|
168
220
|
let newCursor = mapRawCursorToDisplay(formatted, rawCursor);
|
|
169
221
|
// Rejected characters keep the cursor before the ignored input.
|
|
@@ -178,7 +230,7 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
|
|
|
178
230
|
requestCursorRestore();
|
|
179
231
|
}
|
|
180
232
|
onChange?.(sanitized);
|
|
181
|
-
}, [displayValue, onChange]);
|
|
233
|
+
}, [displayValue, onChange, maxIntegerDigits, decimalPlaces]);
|
|
182
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 }));
|
|
183
235
|
});
|
|
184
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,
|
|
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 });
|
|
@@ -114,12 +114,12 @@ export function Popover({ renderTrigger, renderContent, open, onOpenStateChanged
|
|
|
114
114
|
const documentActiveElement = document.activeElement;
|
|
115
115
|
// In Shadow DOM environments, we need to handle focus detection differently
|
|
116
116
|
const isInShadowDOM = rootNode instanceof ShadowRoot;
|
|
117
|
-
|
|
117
|
+
// Both branches below assign this, so it deliberately has no initial value.
|
|
118
|
+
let isFocusStillWithinComponent;
|
|
118
119
|
if (isInShadowDOM) {
|
|
119
120
|
// In Shadow DOM: check both shadow and document active elements
|
|
120
121
|
isFocusStillWithinComponent =
|
|
121
|
-
isElementWithinComponent(shadowActiveElement) ||
|
|
122
|
-
isElementWithinComponent(documentActiveElement);
|
|
122
|
+
isElementWithinComponent(shadowActiveElement) || isElementWithinComponent(documentActiveElement);
|
|
123
123
|
// Special case: If shadow activeElement is null, it means focus has left the shadow DOM.
|
|
124
124
|
// We should trigger onBlur unless the document activeElement is still within our component
|
|
125
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
|
|
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:
|
|
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' &&
|
|
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
|
-
|
|
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.
|
|
@@ -41,13 +41,11 @@ export function useSidePaneStateController({ open, onClose, ref, insideElement }
|
|
|
41
41
|
* Handles the close action for the side pane.
|
|
42
42
|
*/
|
|
43
43
|
const handleCloseSidePane = useCallback(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
44
|
+
// When `open` is controlled by the consumer, only notify; the consumer owns the state.
|
|
45
|
+
if (open === undefined) {
|
|
48
46
|
closeSidePane();
|
|
49
|
-
onClose?.();
|
|
50
47
|
}
|
|
48
|
+
onClose?.();
|
|
51
49
|
}, [closeSidePane, open, onClose]);
|
|
52
50
|
// Function to prevent the side pane from closing when clicking inside the side pane.
|
|
53
51
|
const handleStopCloseSidePane = useCallback((event) => {
|
|
@@ -10,6 +10,6 @@ import { type CSSProperties } from 'react';
|
|
|
10
10
|
*
|
|
11
11
|
* @returns Number value in pixels
|
|
12
12
|
*
|
|
13
|
-
* @throws Error
|
|
13
|
+
* @throws {Error} If value is undefined or invalid
|
|
14
14
|
*/
|
|
15
15
|
export declare function parseSizeToNumber(value: CSSProperties['width'], propName?: string): number;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @returns Number value in pixels
|
|
11
11
|
*
|
|
12
|
-
* @throws Error
|
|
12
|
+
* @throws {Error} If value is undefined or invalid
|
|
13
13
|
*/
|
|
14
14
|
export function parseSizeToNumber(value, propName) {
|
|
15
15
|
const propPrefix = propName ? `SplitView: ${propName}` : 'SplitView';
|
|
@@ -23,7 +23,9 @@ export function parseSizeToNumber(value, propName) {
|
|
|
23
23
|
// Support pixel values like '300px'
|
|
24
24
|
const match = /^(\d+(?:\.\d+)?)px$/.exec(value);
|
|
25
25
|
if (match?.[1]) {
|
|
26
|
-
|
|
26
|
+
// The capture group is `\d+(?:\.\d+)?`, so `Number` matches the previous
|
|
27
|
+
// `Number.parseFloat` exactly.
|
|
28
|
+
return Number(match[1]);
|
|
27
29
|
}
|
|
28
30
|
// Warn about unsupported string formats but don't throw to avoid breaking the component
|
|
29
31
|
const warningMessage = propName
|