@moneyforward/mfui-components 4.2.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/DateTimeSelection/shared/CalendarGrid/utilities/computeDataForStyling.js +2 -4
- 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/NumberBox/NumberBox.js +2 -1
- package/dist/src/Popover/Popover.js +2 -1
- 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/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/Toast/api/toast.d.ts +34 -0
- package/dist/src/Toast/api/toast.js +41 -0
- package/dist/src/Tooltip/hooks/useOpenStateDelayed.js +2 -2
- 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/useOnClickOutside.js +3 -1
- package/dist/styled-system/types/composition.d.ts +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
|
@@ -14,9 +14,8 @@ export function computeDataForStyling(viewingValue, weekStart) {
|
|
|
14
14
|
const firstDateDay = viewingValue.getDay();
|
|
15
15
|
// The last column of the grid, i.e. the day right before the week starts over
|
|
16
16
|
const weekEnd = (weekStart + 6) % 7;
|
|
17
|
-
const datesHasBorderTopLeftRadius = new Set();
|
|
18
17
|
// First date of the month has borderTopLeftRadius
|
|
19
|
-
datesHasBorderTopLeftRadius
|
|
18
|
+
const datesHasBorderTopLeftRadius = new Set([1]);
|
|
20
19
|
// First date sitting at the start of a row has borderTopLeftRadius
|
|
21
20
|
const firstRowStartDate = firstDate + ((weekStart - firstDateDay + 7) % 7);
|
|
22
21
|
datesHasBorderTopLeftRadius.add(firstRowStartDate);
|
|
@@ -29,9 +28,8 @@ export function computeDataForStyling(viewingValue, weekStart) {
|
|
|
29
28
|
// Last date sitting at the start of a row has borderBottomLeftRadius
|
|
30
29
|
const lastRowStartDate = lastDate.getDate() - ((lastDate.getDay() - weekStart + 7) % 7);
|
|
31
30
|
datesHasBorderBottomLeftRadius.add(lastRowStartDate);
|
|
32
|
-
const datesHasBorderBottomRightRadius = new Set();
|
|
33
31
|
// Last date of the month has borderBottomRightRadius
|
|
34
|
-
datesHasBorderBottomRightRadius
|
|
32
|
+
const datesHasBorderBottomRightRadius = new Set([lastDate.getDate()]);
|
|
35
33
|
// Last date sitting at the end of a row has borderBottomRightRadius
|
|
36
34
|
const lastRowEndDate = lastDate.getDate() - ((lastDate.getDay() - weekEnd + 7) % 7);
|
|
37
35
|
datesHasBorderBottomRightRadius.add(lastRowEndDate);
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* - **Controlled/uncontrolled modes**: Proper state management for both patterns
|
|
16
16
|
* - **Provide 'onClear' (user-initiated only)**: Clear the file selection
|
|
17
17
|
* - **Provide 'onValueCleared' (external changes only)**: Called when controlled value transitions from non-empty to empty
|
|
18
|
-
* - **Drag and drop support**: Visual feedback and enhanced file selection UX
|
|
18
|
+
* - **Drag and drop support**: Visual feedback and enhanced file selection UX.
|
|
19
|
+
* Dropped files are assigned to the underlying `<input type="file">`, so they are included in
|
|
20
|
+
* native form submission (`new FormData(form)`) just like files chosen through the dialog.
|
|
21
|
+
* Without `multiple`, only the first dropped file is kept, matching what the dialog allows.
|
|
22
|
+
* In controlled mode the input is reset after every selection by design, for both paths.
|
|
19
23
|
*
|
|
20
24
|
* ## Internationalization
|
|
21
25
|
* This component supports internationalization through text override props following the MFUI Component Internationalization Strategy.
|
|
@@ -8,6 +8,8 @@ import { ClearButton } from '../shared/ClearButton';
|
|
|
8
8
|
import { HelpMessage } from '../HelpMessage';
|
|
9
9
|
import { Button } from '../Button';
|
|
10
10
|
import { mergeRefs } from '../utilities/dom/mergeRefs';
|
|
11
|
+
import { setInputFiles } from '../utilities/dom/setInputFiles';
|
|
12
|
+
import { toFileList } from '../utilities/dom/toFileList';
|
|
11
13
|
import { useIsNarrowViewport } from '../utilities/responsive/useIsNarrowViewport';
|
|
12
14
|
/**
|
|
13
15
|
* The component for selecting a single file or multiple files for upload by dialog or drag and drop.
|
|
@@ -26,7 +28,11 @@ import { useIsNarrowViewport } from '../utilities/responsive/useIsNarrowViewport
|
|
|
26
28
|
* - **Controlled/uncontrolled modes**: Proper state management for both patterns
|
|
27
29
|
* - **Provide 'onClear' (user-initiated only)**: Clear the file selection
|
|
28
30
|
* - **Provide 'onValueCleared' (external changes only)**: Called when controlled value transitions from non-empty to empty
|
|
29
|
-
* - **Drag and drop support**: Visual feedback and enhanced file selection UX
|
|
31
|
+
* - **Drag and drop support**: Visual feedback and enhanced file selection UX.
|
|
32
|
+
* Dropped files are assigned to the underlying `<input type="file">`, so they are included in
|
|
33
|
+
* native form submission (`new FormData(form)`) just like files chosen through the dialog.
|
|
34
|
+
* Without `multiple`, only the first dropped file is kept, matching what the dialog allows.
|
|
35
|
+
* In controlled mode the input is reset after every selection by design, for both paths.
|
|
30
36
|
*
|
|
31
37
|
* ## Internationalization
|
|
32
38
|
* This component supports internationalization through text override props following the MFUI Component Internationalization Strategy.
|
|
@@ -160,15 +166,29 @@ fileSelectButtonLabel = 'ファイルを選択', instructionText, fileCountLabel
|
|
|
160
166
|
}
|
|
161
167
|
const { files } = event.dataTransfer;
|
|
162
168
|
if (files.length > 0) {
|
|
163
|
-
|
|
169
|
+
/*
|
|
170
|
+
* The drop is handled on the container instead of the input element, so the browser
|
|
171
|
+
* applies none of the input's own behaviour to it: it neither assigns the dropped files
|
|
172
|
+
* to the input nor honours the `multiple` attribute. Copying the files over keeps native
|
|
173
|
+
* form submission (`new FormData(form)`) working for the drag and drop path, and keeping
|
|
174
|
+
* only the first file when `multiple` is not set prevents a selection that the file
|
|
175
|
+
* selection dialog could never produce for the same markup.
|
|
176
|
+
*/
|
|
177
|
+
const droppedFiles = [...files];
|
|
178
|
+
const acceptedFiles = multiple ? droppedFiles : droppedFiles.slice(0, 1);
|
|
179
|
+
const input = inputRef.current;
|
|
180
|
+
const isSynced = input !== null && setInputFiles(input, acceptedFiles);
|
|
181
|
+
/*
|
|
182
|
+
* Create a synthetic change event to reuse existing logic. When the files could not be
|
|
183
|
+
* copied over, `target` stands in for the input and carries them directly, so a drop
|
|
184
|
+
* still updates the visible selection.
|
|
185
|
+
*/
|
|
164
186
|
const syntheticEvent = {
|
|
165
|
-
target: {
|
|
166
|
-
files,
|
|
167
|
-
},
|
|
187
|
+
target: isSynced ? input : { files: toFileList(acceptedFiles) },
|
|
168
188
|
};
|
|
169
189
|
handleChange(syntheticEvent);
|
|
170
190
|
}
|
|
171
|
-
}, [disabled, handleChange]);
|
|
191
|
+
}, [disabled, multiple, handleChange]);
|
|
172
192
|
const handleClear = useCallback(() => {
|
|
173
193
|
if (!isControlled) {
|
|
174
194
|
setInternalValues(undefined);
|
|
@@ -98,6 +98,16 @@ export type FileDropZoneProps = {
|
|
|
98
98
|
* The callback function to be called when files are selected through dialog or drag-and-drop.
|
|
99
99
|
* This is the standard HTML input onChange event.
|
|
100
100
|
*
|
|
101
|
+
* **Note:** For the drag-and-drop path the event is a synthetic object rather than a React event.
|
|
102
|
+
* Its `target` is the underlying `<input type="file">`, which the dropped files are assigned to
|
|
103
|
+
* beforehand, but other event members such as `currentTarget` are not populated. In the rare
|
|
104
|
+
* environments where that assignment is not possible, `target` is a plain `{ files }` object
|
|
105
|
+
* instead and the dropped files do not reach native form submission.
|
|
106
|
+
*
|
|
107
|
+
* **Note:** A drop lands on the container rather than on the input, so the `multiple` attribute
|
|
108
|
+
* is applied by this component instead of the browser. Without `multiple`, only the first
|
|
109
|
+
* dropped file is passed on.
|
|
110
|
+
*
|
|
101
111
|
* @param event - The change event from the file input element
|
|
102
112
|
*
|
|
103
113
|
* @default undefined
|
|
@@ -117,7 +117,8 @@ export const NumberBox = forwardRef(({ onChange, value, defaultValue, textAlign
|
|
|
117
117
|
// Sync with controlled value
|
|
118
118
|
useEffect(() => {
|
|
119
119
|
if (value !== undefined) {
|
|
120
|
-
|
|
120
|
+
const sanitized = sanitizeInput(String(value));
|
|
121
|
+
setDisplayValue(formatWithCommas(sanitized));
|
|
121
122
|
}
|
|
122
123
|
}, [value]);
|
|
123
124
|
// Restore cursor position after display value update
|
|
@@ -114,7 +114,8 @@ 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 =
|
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type TabItemProps } from './TabItem';
|
|
2
|
+
import { type TabElementRef } from './hooks/useTabRegistry';
|
|
2
3
|
type TabContextType = {
|
|
3
4
|
selectedTabId: string | undefined;
|
|
4
5
|
onSelectedTabIdChange?: (tabId: string) => void;
|
|
@@ -6,7 +7,8 @@ type TabContextType = {
|
|
|
6
7
|
shouldShowMenu: boolean;
|
|
7
8
|
setShouldShowMenu?: (shouldShowMenu: boolean) => void;
|
|
8
9
|
targetDOMNode?: HTMLElement;
|
|
9
|
-
registerTab?: (tab: TabItemProps) => void;
|
|
10
|
+
registerTab?: (tab: TabItemProps, elementRef: TabElementRef) => void;
|
|
11
|
+
updateTab?: (tab: TabItemProps) => void;
|
|
10
12
|
unregisterTab?: (tabId: string) => void;
|
|
11
13
|
enableAutoUnmount: boolean;
|
|
12
14
|
};
|
|
@@ -10,7 +10,7 @@ import { useArrowKeyHandler } from './hooks/useArrowKeyHandler';
|
|
|
10
10
|
import { Badge } from '../../Badge';
|
|
11
11
|
import { cx } from '../../../styled-system/css';
|
|
12
12
|
export function TabItem({ id, badgeProps, children, className }) {
|
|
13
|
-
const { selectedTabId, onSelectedTabIdChange, tabs, shouldShowMenu, targetDOMNode, registerTab, unregisterTab } = useContext(TabContext);
|
|
13
|
+
const { selectedTabId, onSelectedTabIdChange, tabs, shouldShowMenu, targetDOMNode, registerTab, updateTab, unregisterTab, } = useContext(TabContext);
|
|
14
14
|
const { onKeyDown } = useArrowKeyHandler();
|
|
15
15
|
const buttonRef = useRef(null);
|
|
16
16
|
const labelRef = useRef(null);
|
|
@@ -40,12 +40,23 @@ export function TabItem({ id, badgeProps, children, className }) {
|
|
|
40
40
|
const onClick = useCallback(() => {
|
|
41
41
|
onSelectedTabIdChange?.(id);
|
|
42
42
|
}, [id, onSelectedTabIdChange]);
|
|
43
|
+
// The lifecycle of a registration and the updates to it must stay in two separate effects.
|
|
44
|
+
// Merging them back would unregister and re-register the tab whenever its label or badge
|
|
45
|
+
// changes, which moves it to the end of the registry and gives it the corner radius meant for
|
|
46
|
+
// the last tab. See https://github.com/moneyforward/mfui/issues/2883
|
|
47
|
+
//
|
|
48
|
+
// `badgeProps` and `children` are deliberately absent from the dependencies here: the update
|
|
49
|
+
// effect below carries them, so this effect only runs on mount and unmount.
|
|
43
50
|
useLayoutEffect(() => {
|
|
44
|
-
registerTab?.({ id, badgeProps, children });
|
|
51
|
+
registerTab?.({ id, badgeProps, children }, buttonRef);
|
|
45
52
|
return () => {
|
|
46
53
|
unregisterTab?.(id);
|
|
47
54
|
};
|
|
48
|
-
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
}, [id, registerTab, unregisterTab]);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
updateTab?.({ id, badgeProps, children });
|
|
59
|
+
}, [id, badgeProps, children, updateTab]);
|
|
49
60
|
useLayoutEffect(() => {
|
|
50
61
|
if (!labelRef.current) {
|
|
51
62
|
return;
|
package/dist/src/Tabs/Tabs.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { tabsSlotRecipe } from '../../styled-system/recipes';
|
|
5
5
|
import { useCalculatedSelectedTabId } from './hooks/useCalculatedSelectedTabId';
|
|
6
|
+
import { useTabRegistry } from './hooks/useTabRegistry';
|
|
6
7
|
import { TabItem } from './TabItem';
|
|
7
8
|
import { TabList } from './TabList';
|
|
8
9
|
import { TabPanel } from './TabPanel';
|
|
@@ -13,31 +14,12 @@ function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnm
|
|
|
13
14
|
const classNames = tabsSlotRecipe();
|
|
14
15
|
const [shouldShowMenu, setShouldShowMenu] = useState(false);
|
|
15
16
|
const wrapperRef = useRef(null);
|
|
16
|
-
const
|
|
17
|
+
const { tabs, registerTab, updateTab, unregisterTab } = useTabRegistry();
|
|
17
18
|
const { calculatedSelectedTabId, setCalculatedSelectedTab } = useCalculatedSelectedTabId({
|
|
18
19
|
selectedTabId,
|
|
19
20
|
onSelectedTabChange,
|
|
20
21
|
tabs,
|
|
21
22
|
});
|
|
22
|
-
const registeringTabs = useRef({});
|
|
23
|
-
const { registerTab, unregisterTab } = useMemo(() => {
|
|
24
|
-
const registerTab = (tab) => {
|
|
25
|
-
registeringTabs.current[tab.id] = tab;
|
|
26
|
-
setTabs(Object.values(registeringTabs.current));
|
|
27
|
-
};
|
|
28
|
-
const unregisterTab = (tabId) => {
|
|
29
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
30
|
-
delete registeringTabs.current[tabId];
|
|
31
|
-
setTabs(Object.values(registeringTabs.current));
|
|
32
|
-
};
|
|
33
|
-
return {
|
|
34
|
-
registerTab,
|
|
35
|
-
unregisterTab,
|
|
36
|
-
};
|
|
37
|
-
}, []);
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
setTabs(Object.values(registeringTabs.current));
|
|
40
|
-
}, []);
|
|
41
23
|
const contextValue = useMemo(() => ({
|
|
42
24
|
selectedTabId: calculatedSelectedTabId,
|
|
43
25
|
onSelectedTabIdChange: setCalculatedSelectedTab,
|
|
@@ -46,6 +28,7 @@ function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnm
|
|
|
46
28
|
setShouldShowMenu,
|
|
47
29
|
targetDOMNode: getTargetDomNode(targetDOMNode, wrapperRef.current),
|
|
48
30
|
registerTab,
|
|
31
|
+
updateTab,
|
|
49
32
|
unregisterTab,
|
|
50
33
|
enableAutoUnmount,
|
|
51
34
|
}), [
|
|
@@ -56,6 +39,7 @@ function Base({ selectedTabId, onSelectedTabChange, targetDOMNode, enableAutoUnm
|
|
|
56
39
|
setShouldShowMenu,
|
|
57
40
|
targetDOMNode,
|
|
58
41
|
registerTab,
|
|
42
|
+
updateTab,
|
|
59
43
|
unregisterTab,
|
|
60
44
|
enableAutoUnmount,
|
|
61
45
|
]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type TabItemProps } from '../TabItem';
|
|
3
|
+
/**
|
|
4
|
+
* A reference to the rendered tab element, used to order the registry by document position.
|
|
5
|
+
* It is a ref rather than the element itself because a `TabItem` swaps its button for a new
|
|
6
|
+
* node whenever its tooltip wrapper appears or disappears.
|
|
7
|
+
*/
|
|
8
|
+
export type TabElementRef = RefObject<HTMLElement | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Custom hook to manage the tabs registered by the `TabItem` components.
|
|
11
|
+
*
|
|
12
|
+
* The published list is ordered by the position of the tabs in the document, which is how the
|
|
13
|
+
* guideline defines the `start` / `middle` / `end` styles, and it is also the order the tab
|
|
14
|
+
* dropdown and the uncontrolled default selection rely on. Ordering by registration recency
|
|
15
|
+
* instead would move a tab whose label or badge changed after mount to the end of the list.
|
|
16
|
+
*
|
|
17
|
+
* @returns The ordered tab list and the callbacks a `TabItem` uses to register itself.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useTabRegistry(): {
|
|
20
|
+
tabs: TabItemProps[];
|
|
21
|
+
registerTab: (props: TabItemProps, elementRef: TabElementRef) => void;
|
|
22
|
+
updateTab: (props: TabItemProps) => void;
|
|
23
|
+
unregisterTab: (tabId: string) => void;
|
|
24
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useMemo, useRef, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Compares two `badgeProps` objects field by field.
|
|
5
|
+
* Consumers usually pass an inline object literal, so comparing by reference would report
|
|
6
|
+
* a change on every render. The comparison must stay field by field, including `aria-label`,
|
|
7
|
+
* so a badge whose count and accessible label resolve after mount still propagates.
|
|
8
|
+
*
|
|
9
|
+
* @param a - The badge props of the already published tab.
|
|
10
|
+
* @param b - The badge props the tab wants to publish.
|
|
11
|
+
*
|
|
12
|
+
* @returns `true` when every field holds the same value.
|
|
13
|
+
*/
|
|
14
|
+
function areBadgePropsEqual(a, b) {
|
|
15
|
+
if (a === b)
|
|
16
|
+
return true;
|
|
17
|
+
if (!a || !b)
|
|
18
|
+
return false;
|
|
19
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
20
|
+
return [...keys].every((key) => Object.is(a[key], b[key]));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Compares two published tab lists.
|
|
24
|
+
* Returning `true` lets the registry keep the previous array identity, which is what stops a
|
|
25
|
+
* `TabItem` re-rendered with an equivalent-but-new `badgeProps` object from feeding itself.
|
|
26
|
+
*
|
|
27
|
+
* @param a - The already published tab list.
|
|
28
|
+
* @param b - The tab list the registry wants to publish.
|
|
29
|
+
*
|
|
30
|
+
* @returns `true` when both lists hold the same tabs, in the same order, with the same props.
|
|
31
|
+
*/
|
|
32
|
+
function areTabListsEqual(a, b) {
|
|
33
|
+
if (a === b)
|
|
34
|
+
return true;
|
|
35
|
+
if (a.length !== b.length)
|
|
36
|
+
return false;
|
|
37
|
+
return a.every((tab, index) => {
|
|
38
|
+
const other = b[index];
|
|
39
|
+
return (other !== undefined &&
|
|
40
|
+
tab.id === other.id &&
|
|
41
|
+
Object.is(tab.children, other.children) &&
|
|
42
|
+
tab.className === other.className &&
|
|
43
|
+
areBadgePropsEqual(tab.badgeProps, other.badgeProps));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sorts the entries in place so they follow the order the tabs appear in the document.
|
|
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.
|
|
50
|
+
*
|
|
51
|
+
* @param entries - The registered entries, sorted in place.
|
|
52
|
+
*/
|
|
53
|
+
function sortByDocumentPosition(entries) {
|
|
54
|
+
entries.sort((a, b) => {
|
|
55
|
+
const aElement = a.elementRef.current;
|
|
56
|
+
const bElement = b.elementRef.current;
|
|
57
|
+
if (!aElement?.isConnected || !bElement?.isConnected)
|
|
58
|
+
return 0;
|
|
59
|
+
const relation = aElement.compareDocumentPosition(bElement);
|
|
60
|
+
// eslint-disable-next-line no-bitwise
|
|
61
|
+
if (relation & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
62
|
+
return -1;
|
|
63
|
+
// eslint-disable-next-line no-bitwise
|
|
64
|
+
if (relation & Node.DOCUMENT_POSITION_PRECEDING)
|
|
65
|
+
return 1;
|
|
66
|
+
return 0;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Custom hook to manage the tabs registered by the `TabItem` components.
|
|
71
|
+
*
|
|
72
|
+
* The published list is ordered by the position of the tabs in the document, which is how the
|
|
73
|
+
* guideline defines the `start` / `middle` / `end` styles, and it is also the order the tab
|
|
74
|
+
* dropdown and the uncontrolled default selection rely on. Ordering by registration recency
|
|
75
|
+
* instead would move a tab whose label or badge changed after mount to the end of the list.
|
|
76
|
+
*
|
|
77
|
+
* @returns The ordered tab list and the callbacks a `TabItem` uses to register itself.
|
|
78
|
+
*/
|
|
79
|
+
export function useTabRegistry() {
|
|
80
|
+
const [tabs, setTabs] = useState([]);
|
|
81
|
+
const entriesRef = useRef([]);
|
|
82
|
+
const { registerTab, updateTab, unregisterTab } = useMemo(() => {
|
|
83
|
+
const publish = () => {
|
|
84
|
+
sortByDocumentPosition(entriesRef.current);
|
|
85
|
+
const nextTabs = entriesRef.current.map((entry) => entry.props);
|
|
86
|
+
setTabs((previousTabs) => (areTabListsEqual(previousTabs, nextTabs) ? previousTabs : nextTabs));
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
/**
|
|
90
|
+
* Registers a tab, or replaces it in place when the id is already registered.
|
|
91
|
+
*
|
|
92
|
+
* @param props - The props of the tab to register.
|
|
93
|
+
* @param elementRef - The reference to the rendered tab element.
|
|
94
|
+
*/
|
|
95
|
+
registerTab: (props, elementRef) => {
|
|
96
|
+
const entry = entriesRef.current.find((registered) => registered.props.id === props.id);
|
|
97
|
+
if (entry) {
|
|
98
|
+
entry.props = props;
|
|
99
|
+
entry.elementRef = elementRef;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
entriesRef.current.push({ props, elementRef });
|
|
103
|
+
}
|
|
104
|
+
publish();
|
|
105
|
+
},
|
|
106
|
+
/**
|
|
107
|
+
* Updates the props of an already registered tab without changing its place in the list.
|
|
108
|
+
*
|
|
109
|
+
* @param props - The new props of the tab.
|
|
110
|
+
*/
|
|
111
|
+
updateTab: (props) => {
|
|
112
|
+
const entry = entriesRef.current.find((registered) => registered.props.id === props.id);
|
|
113
|
+
if (!entry)
|
|
114
|
+
return;
|
|
115
|
+
entry.props = props;
|
|
116
|
+
publish();
|
|
117
|
+
},
|
|
118
|
+
/**
|
|
119
|
+
* Removes a tab from the registry. The remaining tabs keep their relative order.
|
|
120
|
+
*
|
|
121
|
+
* @param tabId - The ID of the tab to remove.
|
|
122
|
+
*/
|
|
123
|
+
unregisterTab: (tabId) => {
|
|
124
|
+
entriesRef.current = entriesRef.current.filter((registered) => registered.props.id !== tabId);
|
|
125
|
+
publish();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
return { tabs, registerTab, updateTab, unregisterTab };
|
|
130
|
+
}
|
|
@@ -12,9 +12,42 @@ declare const showToast: (props: ToastApiProps | string, type?: ToastSlotRecipeV
|
|
|
12
12
|
* Destroy a toast message.
|
|
13
13
|
* If the key is not provided, all toasts will be destroyed.
|
|
14
14
|
*
|
|
15
|
+
* This only hides the toasts; the internal React root stays mounted. To also
|
|
16
|
+
* unmount that root and remove its container, use {@link toast.reset}.
|
|
17
|
+
*
|
|
15
18
|
* @param key - The key of the toast message to destroy.
|
|
16
19
|
*/
|
|
17
20
|
declare const destroyToast: (key?: string) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Tear down the toast renderer completely.
|
|
23
|
+
*
|
|
24
|
+
* Unmounts the internal React root, removes the container element it created,
|
|
25
|
+
* and resets every module-scoped reference, so the next toast starts from the
|
|
26
|
+
* same state as the very first one. Calling it before any toast has been shown,
|
|
27
|
+
* or twice in a row, is a safe no-op.
|
|
28
|
+
*
|
|
29
|
+
* This is intended for test teardown and application shutdown. To only hide the
|
|
30
|
+
* currently displayed toasts, use {@link toast.destroy} instead.
|
|
31
|
+
*
|
|
32
|
+
* The unmount flushes synchronously, which has two consequences: do not call it
|
|
33
|
+
* from a render or an effect cleanup, and wrap it in `act()` in tests. Calling
|
|
34
|
+
* it bare in a test logs "An update to Root inside a test was not wrapped in
|
|
35
|
+
* act(...)".
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { act } from '@testing-library/react';
|
|
40
|
+
* import { afterEach } from 'vitest';
|
|
41
|
+
* import { toast } from '@moneyforward/mfui-components/Toast';
|
|
42
|
+
*
|
|
43
|
+
* afterEach(() => {
|
|
44
|
+
* act(() => {
|
|
45
|
+
* toast.reset();
|
|
46
|
+
* });
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare const resetToast: () => void;
|
|
18
51
|
export declare const toast: {
|
|
19
52
|
show: typeof showToast;
|
|
20
53
|
success: (props: ToastApiProps | string) => void;
|
|
@@ -23,5 +56,6 @@ export declare const toast: {
|
|
|
23
56
|
progress: (props: ToastApiProps | string) => void;
|
|
24
57
|
caution: (props: ToastApiProps | string) => void;
|
|
25
58
|
destroy: typeof destroyToast;
|
|
59
|
+
reset: typeof resetToast;
|
|
26
60
|
};
|
|
27
61
|
export {};
|
|
@@ -95,11 +95,51 @@ const showToast = (props, type = 'custom') => {
|
|
|
95
95
|
* Destroy a toast message.
|
|
96
96
|
* If the key is not provided, all toasts will be destroyed.
|
|
97
97
|
*
|
|
98
|
+
* This only hides the toasts; the internal React root stays mounted. To also
|
|
99
|
+
* unmount that root and remove its container, use {@link toast.reset}.
|
|
100
|
+
*
|
|
98
101
|
* @param key - The key of the toast message to destroy.
|
|
99
102
|
*/
|
|
100
103
|
const destroyToast = (key) => {
|
|
101
104
|
removeToast(key);
|
|
102
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Tear down the toast renderer completely.
|
|
108
|
+
*
|
|
109
|
+
* Unmounts the internal React root, removes the container element it created,
|
|
110
|
+
* and resets every module-scoped reference, so the next toast starts from the
|
|
111
|
+
* same state as the very first one. Calling it before any toast has been shown,
|
|
112
|
+
* or twice in a row, is a safe no-op.
|
|
113
|
+
*
|
|
114
|
+
* This is intended for test teardown and application shutdown. To only hide the
|
|
115
|
+
* currently displayed toasts, use {@link toast.destroy} instead.
|
|
116
|
+
*
|
|
117
|
+
* The unmount flushes synchronously, which has two consequences: do not call it
|
|
118
|
+
* from a render or an effect cleanup, and wrap it in `act()` in tests. Calling
|
|
119
|
+
* it bare in a test logs "An update to Root inside a test was not wrapped in
|
|
120
|
+
* act(...)".
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* import { act } from '@testing-library/react';
|
|
125
|
+
* import { afterEach } from 'vitest';
|
|
126
|
+
* import { toast } from '@moneyforward/mfui-components/Toast';
|
|
127
|
+
*
|
|
128
|
+
* afterEach(() => {
|
|
129
|
+
* act(() => {
|
|
130
|
+
* toast.reset();
|
|
131
|
+
* });
|
|
132
|
+
* });
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
const resetToast = () => {
|
|
136
|
+
root?.unmount();
|
|
137
|
+
rootElement?.remove();
|
|
138
|
+
root = null;
|
|
139
|
+
rootElement = null;
|
|
140
|
+
rootWrapper = undefined;
|
|
141
|
+
toastList = [];
|
|
142
|
+
};
|
|
103
143
|
export const toast = {
|
|
104
144
|
show: showToast,
|
|
105
145
|
success: (props) => {
|
|
@@ -118,4 +158,5 @@ export const toast = {
|
|
|
118
158
|
showToast(props, 'caution');
|
|
119
159
|
},
|
|
120
160
|
destroy: destroyToast,
|
|
161
|
+
reset: resetToast,
|
|
121
162
|
};
|
|
@@ -16,8 +16,9 @@ export function useOpenStateDelayed(tooltipOpen, onActualClose) {
|
|
|
16
16
|
const openTimeout = useRef(null);
|
|
17
17
|
const closeTimeout = useRef(null);
|
|
18
18
|
const setOpenDelayed = useCallback((value) => {
|
|
19
|
+
// A pending open is cancelled either way; only the rest of the handling differs.
|
|
20
|
+
openTimeout.current && clearTimeout(openTimeout.current);
|
|
19
21
|
if (value) {
|
|
20
|
-
openTimeout.current && clearTimeout(openTimeout.current);
|
|
21
22
|
closeTimeout.current && clearTimeout(closeTimeout.current);
|
|
22
23
|
closeTimeout.current = null;
|
|
23
24
|
openTimeout.current = window.setTimeout(() => {
|
|
@@ -26,7 +27,6 @@ export function useOpenStateDelayed(tooltipOpen, onActualClose) {
|
|
|
26
27
|
}, OPEN_DELAY);
|
|
27
28
|
}
|
|
28
29
|
else {
|
|
29
|
-
openTimeout.current && clearTimeout(openTimeout.current);
|
|
30
30
|
openTimeout.current = null;
|
|
31
31
|
closeTimeout.current = window.setTimeout(() => {
|
|
32
32
|
setOpen(false);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assigns the given files to a `<input type="file">` element.
|
|
3
|
+
*
|
|
4
|
+
* The browser only fills `input.files` by itself when the drop lands on the input element and the
|
|
5
|
+
* default action is not prevented. Components that intercept the drop on a surrounding container
|
|
6
|
+
* have to perform the assignment themselves, otherwise the input stays empty and native form
|
|
7
|
+
* serialization (`new FormData(form)`) silently omits the file.
|
|
8
|
+
*
|
|
9
|
+
* `input.files` only accepts a `FileList`, which cannot be constructed directly, so the files are
|
|
10
|
+
* copied into a fresh `DataTransfer` and its `files` list is assigned.
|
|
11
|
+
*
|
|
12
|
+
* @param input - The file input element to assign the files to
|
|
13
|
+
*
|
|
14
|
+
* @param files - The files to assign
|
|
15
|
+
*
|
|
16
|
+
* @returns `true` when the files were assigned, `false` when the environment does not support
|
|
17
|
+
* `DataTransfer` or rejected the assignment
|
|
18
|
+
*
|
|
19
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/files
|
|
20
|
+
*/
|
|
21
|
+
export declare const setInputFiles: (input: HTMLInputElement, files: Iterable<File>) => boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assigns the given files to a `<input type="file">` element.
|
|
3
|
+
*
|
|
4
|
+
* The browser only fills `input.files` by itself when the drop lands on the input element and the
|
|
5
|
+
* default action is not prevented. Components that intercept the drop on a surrounding container
|
|
6
|
+
* have to perform the assignment themselves, otherwise the input stays empty and native form
|
|
7
|
+
* serialization (`new FormData(form)`) silently omits the file.
|
|
8
|
+
*
|
|
9
|
+
* `input.files` only accepts a `FileList`, which cannot be constructed directly, so the files are
|
|
10
|
+
* copied into a fresh `DataTransfer` and its `files` list is assigned.
|
|
11
|
+
*
|
|
12
|
+
* @param input - The file input element to assign the files to
|
|
13
|
+
*
|
|
14
|
+
* @param files - The files to assign
|
|
15
|
+
*
|
|
16
|
+
* @returns `true` when the files were assigned, `false` when the environment does not support
|
|
17
|
+
* `DataTransfer` or rejected the assignment
|
|
18
|
+
*
|
|
19
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/files
|
|
20
|
+
*/
|
|
21
|
+
export const setInputFiles = (input, files) => {
|
|
22
|
+
if (typeof DataTransfer === 'undefined') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const dataTransfer = new DataTransfer();
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
dataTransfer.items.add(file);
|
|
29
|
+
}
|
|
30
|
+
// eslint-disable-next-line no-param-reassign -- Assigning the files to the given input is the purpose of this utility
|
|
31
|
+
input.files = dataTransfer.files;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/*
|
|
36
|
+
* Some engines expose `DataTransfer` but forbid constructing it or mutating its items outside
|
|
37
|
+
* of a drag operation. Failing to sync must not break the visible file selection.
|
|
38
|
+
*/
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|