@moneyforward/mfui-components 4.1.0 → 4.1.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.
@@ -8,6 +8,8 @@ import { IconButton } from '../IconButton';
8
8
  import { Typography } from '../Typography';
9
9
  import { mergeRefs } from '../utilities/dom/mergeRefs';
10
10
  import { ProgressIndicator } from '../ProgressIndicator';
11
+ import { useLatest } from '../utilities/state/useLatest';
12
+ const MIN_AUTO_DISMISS_DURATION_MS = 5000;
11
13
  /**
12
14
  * The general-purpose Toast component.
13
15
  * This component switches the variants of looks depends on the props: type and actionText.
@@ -17,7 +19,10 @@ export const Toast = forwardRef(({ className, type = 'success', actionSlot, mess
17
19
  const toastRef = useRef(null);
18
20
  const timerRef = useRef(null);
19
21
  const startTimeRef = useRef(null);
20
- const localDurationRef = useRef(null);
22
+ const remainingDurationRef = useRef(0);
23
+ // Prevent duplicate callbacks when a consumer keeps an auto-dismissed Toast mounted.
24
+ const hasAutoDismissedRef = useRef(false);
25
+ const latestOnClose = useLatest(onClose);
21
26
  // The status is conveyed by the message content itself, so the status icon is decorative
22
27
  // and hidden from assistive technology unless a label is explicitly provided.
23
28
  const statusIconLabel = statusIconProps?.['aria-label'];
@@ -37,46 +42,54 @@ export const Toast = forwardRef(({ className, type = 'success', actionSlot, mess
37
42
  }
38
43
  return _jsx(Pending, { ...statusIconA11yProps });
39
44
  })();
40
- // Handle the duration of the toast
45
+ // Keep cleanup tied to the duration lifecycle. Cleaning up in the focus-driven effect below
46
+ // would discard the remaining duration whenever focus enters or leaves the Toast.
41
47
  useEffect(() => {
42
- // If the duration is not provided or under `5000`,
43
- // the toast message will be displayed until it's closed via the close button.
44
- if (!duration || duration < 5000) {
45
- localDurationRef.current = 0;
48
+ remainingDurationRef.current = duration;
49
+ hasAutoDismissedRef.current = false;
50
+ return () => {
46
51
  if (timerRef.current) {
47
52
  clearTimeout(timerRef.current);
48
53
  }
49
- return;
50
- }
51
- localDurationRef.current = duration;
54
+ timerRef.current = null;
55
+ startTimeRef.current = null;
56
+ };
52
57
  }, [duration]);
53
58
  useEffect(() => {
54
- // If localDurationRef is 0, the toast will not be closed automatically.
55
- if (!localDurationRef.current || localDurationRef.current <= 0) {
59
+ if (duration < MIN_AUTO_DISMISS_DURATION_MS || hasAutoDismissedRef.current) {
56
60
  return;
57
61
  }
58
- const isFocusVisible = !!toastRef.current &&
62
+ const isFocusWithinToast = !!toastRef.current &&
59
63
  !!activeElement &&
60
64
  (toastRef.current === activeElement ||
61
65
  activeElement.closest('[data-mfui-content="toast-item"]') === toastRef.current);
62
- // If the toast is not focused, start the timer to close the toast.
63
- if (!isFocusVisible && !timerRef.current) {
64
- startTimeRef.current = Date.now();
65
- timerRef.current = setTimeout(() => {
66
- onClose?.();
67
- }, localDurationRef.current);
68
- return;
69
- }
70
- // If the toast is focused, stop the timer and calculate the left time.
71
- if (isFocusVisible) {
72
- const passedTime = Date.now() - (startTimeRef.current ?? 0);
73
- const leftTime = localDurationRef.current - passedTime;
74
- if (timerRef.current) {
75
- clearTimeout(timerRef.current);
66
+ if (isFocusWithinToast) {
67
+ if (!timerRef.current || startTimeRef.current === null) {
68
+ return;
76
69
  }
70
+ const passedTime = Date.now() - startTimeRef.current;
71
+ clearTimeout(timerRef.current);
77
72
  timerRef.current = null;
78
- localDurationRef.current = leftTime;
73
+ startTimeRef.current = null;
74
+ remainingDurationRef.current = Math.max(remainingDurationRef.current - passedTime, 0);
75
+ return;
76
+ }
77
+ if (timerRef.current) {
78
+ return;
79
79
  }
80
- }, [activeElement, onClose]);
80
+ if (remainingDurationRef.current <= 0) {
81
+ hasAutoDismissedRef.current = true;
82
+ latestOnClose.current?.();
83
+ return;
84
+ }
85
+ startTimeRef.current = Date.now();
86
+ timerRef.current = setTimeout(() => {
87
+ timerRef.current = null;
88
+ startTimeRef.current = null;
89
+ remainingDurationRef.current = 0;
90
+ hasAutoDismissedRef.current = true;
91
+ latestOnClose.current?.();
92
+ }, remainingDurationRef.current);
93
+ }, [activeElement, duration, latestOnClose]);
81
94
  return (_jsx(FocusIndicator, { children: _jsxs("div", { ref: mergeRefs(ref, toastRef), className: cx(classes.item, 'mfui-Toast__item', className), role: type === 'failure' ? 'alert' : 'status', "aria-live": type === 'failure' ? undefined : 'polite', "data-mfui-content": "toast-item", ...props, children: [_jsxs("div", { className: cx(classes.body, 'mfui-Toast__body'), children: [_jsxs("div", { className: cx(classes.message, 'mfui-Toast__message'), children: [_jsx("div", { className: cx(classes.leftIcon, 'mfui-Toast__leftIcon'), children: customIcon ?? icon }), _jsx(Typography, { children: message })] }), _jsx(IconButton, { "aria-label": closeButtonProps?.['aria-label'] ?? '閉じる', disabled: !showCloseButton, onClick: onClose, children: _jsx(Close, {}) })] }), actionSlot ? _jsx("div", { className: cx(classes.footer, 'mfui-Toast__footer'), children: actionSlot }) : null] }) }));
82
95
  });
@@ -74,14 +74,24 @@ export function ToastContainer({ items, onItemClose }) {
74
74
  }, [items, isMobile]);
75
75
  // Detect focus on the toast or its content
76
76
  useLayoutEffect(() => {
77
- const checkFocusVisible = () => {
78
- setActiveElement(document.activeElement);
77
+ const handleFocus = (event) => {
78
+ if (!(event.target instanceof Element)) {
79
+ return;
80
+ }
81
+ setActiveElement(event.target);
82
+ };
83
+ const handleBlur = (event) => {
84
+ // Losing window focus also has no related target, but DOM focus remains on the same element.
85
+ if (event.relatedTarget === null && !document.hasFocus()) {
86
+ return;
87
+ }
88
+ setActiveElement(event.relatedTarget instanceof Element ? event.relatedTarget : null);
79
89
  };
80
- window.addEventListener('focus', checkFocusVisible, true);
81
- window.addEventListener('blur', checkFocusVisible, true);
90
+ window.addEventListener('focus', handleFocus, true);
91
+ window.addEventListener('blur', handleBlur, true);
82
92
  return () => {
83
- window.removeEventListener('focus', checkFocusVisible);
84
- window.removeEventListener('blur', checkFocusVisible);
93
+ window.removeEventListener('focus', handleFocus, true);
94
+ window.removeEventListener('blur', handleBlur, true);
85
95
  };
86
96
  }, []);
87
97
  // Close the first toast when the "Esc" key is pressed and the toast or its content has a focus
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneyforward/mfui-components",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "React UI Component Library for all Money Forward products",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,7 +51,7 @@
51
51
  "react-dom": "19.2.3",
52
52
  "storybook": "10.5.10",
53
53
  "ts-node": "10.9.2",
54
- "tsx": "4.23.12",
54
+ "tsx": "4.23.13",
55
55
  "vitest": "4.1.11",
56
56
  "oxlint-config-mfui": "^1.0.0"
57
57
  },
@@ -63,8 +63,8 @@
63
63
  "@floating-ui/react-dom": "^2.1.2",
64
64
  "@tanstack/react-virtual": "^3.13.18",
65
65
  "dayjs": "^1.11.13",
66
- "@moneyforward/mfui-design-tokens": "^4.0.0",
67
- "@moneyforward/mfui-icons-react": "^4.0.0"
66
+ "@moneyforward/mfui-icons-react": "^4.0.0",
67
+ "@moneyforward/mfui-design-tokens": "^4.0.0"
68
68
  },
69
69
  "scripts": {
70
70
  "prepare:panda": "panda codegen",