@rainbow-oh/yee-c 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/cjs/Form/form-store.js +3 -0
  2. package/dist/cjs/Input/style/index.less +1 -0
  3. package/dist/cjs/Select/interface.d.ts +1 -1
  4. package/dist/cjs/Selector/style/index.less +1 -0
  5. package/dist/cjs/Table/icon/arrow-icon.js +1 -1
  6. package/dist/cjs/Table/theader/cell.js +6 -3
  7. package/dist/cjs/TextArea/style/index.less +1 -0
  8. package/dist/cjs/VerificationCode/style/index.less +1 -0
  9. package/dist/cjs/hooks/useCountdown.d.ts +2 -1
  10. package/dist/cjs/hooks/useCountdown.js +46 -23
  11. package/dist/cjs/locale/interface.d.ts +3 -0
  12. package/dist/cjs/locale/lang/en_US.js +4 -1
  13. package/dist/cjs/locale/lang/ja_JP.js +4 -1
  14. package/dist/cjs/locale/lang/zh_CN.js +4 -1
  15. package/dist/cjs/locale/lang/zh_TW.js +4 -1
  16. package/dist/cjs/message/demo/base.js +1 -1
  17. package/dist/cjs/message/index.d.ts +1 -1
  18. package/dist/cjs/message/interface.d.ts +16 -7
  19. package/dist/cjs/message/message-provider.d.ts +2 -1
  20. package/dist/cjs/message/message-provider.js +1 -1
  21. package/dist/cjs/message/message.js +14 -9
  22. package/dist/cjs/message/style/index.less +3 -1
  23. package/dist/cjs/message/use-message.d.ts +3 -12
  24. package/dist/cjs/message/use-message.js +103 -45
  25. package/dist/cjs/message/wrapper.d.ts +21 -27
  26. package/dist/cjs/message/wrapper.js +49 -19
  27. package/dist/cjs/notice/index.d.ts +1 -1
  28. package/dist/cjs/notice/interface.d.ts +17 -7
  29. package/dist/cjs/notice/notice-list.d.ts +3 -3
  30. package/dist/cjs/notice/notice-provider.d.ts +2 -1
  31. package/dist/cjs/notice/notice-provider.js +1 -1
  32. package/dist/cjs/notice/notice.js +63 -20
  33. package/dist/cjs/notice/style/index.less +13 -1
  34. package/dist/cjs/notice/use-notice.d.ts +2 -11
  35. package/dist/cjs/notice/use-notice.js +156 -71
  36. package/dist/cjs/notice/wrapper.d.ts +19 -15
  37. package/dist/cjs/notice/wrapper.js +122 -52
  38. package/dist/cjs/style/size.css +3 -3
  39. package/dist/cjs/style/themes.css +1 -1
  40. package/dist/cjs/style/variables.css +4 -4
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/Form/form-store.js +25 -17
  44. package/dist/esm/Input/style/index.less +1 -0
  45. package/dist/esm/Select/interface.d.ts +1 -1
  46. package/dist/esm/Selector/style/index.less +1 -0
  47. package/dist/esm/Table/icon/arrow-icon.js +1 -1
  48. package/dist/esm/Table/theader/cell.js +7 -3
  49. package/dist/esm/TextArea/style/index.less +1 -0
  50. package/dist/esm/VerificationCode/style/index.less +1 -0
  51. package/dist/esm/hooks/useCountdown.d.ts +2 -1
  52. package/dist/esm/hooks/useCountdown.js +44 -14
  53. package/dist/esm/locale/interface.d.ts +3 -0
  54. package/dist/esm/locale/lang/en_US.js +4 -1
  55. package/dist/esm/locale/lang/ja_JP.js +4 -1
  56. package/dist/esm/locale/lang/zh_CN.js +4 -1
  57. package/dist/esm/locale/lang/zh_TW.js +4 -1
  58. package/dist/esm/message/demo/base.js +4 -1
  59. package/dist/esm/message/index.d.ts +1 -1
  60. package/dist/esm/message/interface.d.ts +16 -7
  61. package/dist/esm/message/message-provider.d.ts +2 -1
  62. package/dist/esm/message/message-provider.js +1 -1
  63. package/dist/esm/message/message.js +22 -9
  64. package/dist/esm/message/style/index.less +3 -1
  65. package/dist/esm/message/use-message.d.ts +3 -12
  66. package/dist/esm/message/use-message.js +129 -79
  67. package/dist/esm/message/wrapper.d.ts +21 -27
  68. package/dist/esm/message/wrapper.js +60 -18
  69. package/dist/esm/notice/index.d.ts +1 -1
  70. package/dist/esm/notice/interface.d.ts +17 -7
  71. package/dist/esm/notice/notice-list.d.ts +3 -3
  72. package/dist/esm/notice/notice-provider.d.ts +2 -1
  73. package/dist/esm/notice/notice-provider.js +1 -1
  74. package/dist/esm/notice/notice.js +73 -21
  75. package/dist/esm/notice/style/index.less +13 -1
  76. package/dist/esm/notice/use-notice.d.ts +2 -11
  77. package/dist/esm/notice/use-notice.js +180 -100
  78. package/dist/esm/notice/wrapper.d.ts +19 -15
  79. package/dist/esm/notice/wrapper.js +144 -62
  80. package/dist/esm/style/size.css +3 -3
  81. package/dist/esm/style/themes.css +1 -1
  82. package/dist/esm/style/variables.css +4 -4
  83. package/dist/esm/version.d.ts +1 -1
  84. package/dist/esm/version.js +1 -1
  85. package/dist/umd/yee-c.min.css +2 -2
  86. package/dist/umd/yee-c.min.js +1 -1
  87. package/package.json +1 -1
  88. package/src/Form/form-store.ts +5 -0
  89. package/src/Input/style/index.less +1 -0
  90. package/src/Select/interface.ts +1 -1
  91. package/src/Selector/style/index.less +1 -0
  92. package/src/Table/icon/arrow-icon.tsx +1 -1
  93. package/src/Table/theader/cell.tsx +6 -3
  94. package/src/TextArea/style/index.less +1 -0
  95. package/src/VerificationCode/style/index.less +1 -0
  96. package/src/hooks/useCountdown.ts +52 -21
  97. package/src/locale/interface.ts +3 -0
  98. package/src/locale/lang/en_US.ts +3 -0
  99. package/src/locale/lang/ja_JP.ts +3 -0
  100. package/src/locale/lang/zh_CN.ts +3 -0
  101. package/src/locale/lang/zh_TW.ts +3 -0
  102. package/src/message/demo/base.tsx +1 -1
  103. package/src/message/index.ts +1 -1
  104. package/src/message/interface.ts +22 -7
  105. package/src/message/message-provider.tsx +3 -2
  106. package/src/message/message.test.tsx +283 -0
  107. package/src/message/message.tsx +14 -9
  108. package/src/message/style/index.less +3 -1
  109. package/src/message/use-message.tsx +144 -67
  110. package/src/message/wrapper.tsx +62 -20
  111. package/src/notice/index.en-US.md +76 -75
  112. package/src/notice/index.ts +6 -1
  113. package/src/notice/index.zh-CN.md +3 -2
  114. package/src/notice/interface.ts +31 -14
  115. package/src/notice/notice-list.tsx +3 -9
  116. package/src/notice/notice-provider.tsx +3 -2
  117. package/src/notice/notice.test.tsx +450 -0
  118. package/src/notice/notice.tsx +69 -21
  119. package/src/notice/style/index.less +13 -1
  120. package/src/notice/use-notice.tsx +210 -94
  121. package/src/notice/wrapper.tsx +159 -71
  122. package/src/style/size.css +3 -3
  123. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-oh/yee-c",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "yee basic components",
5
5
  "keywords": [
6
6
  "react",
@@ -242,6 +242,11 @@ export class FormStore {
242
242
  return false;
243
243
  });
244
244
  }
245
+ // If no rules match the current trigger, skip validation entirely
246
+ // to avoid clearing existing errors from a previous validation pass.
247
+ if (trigger && mergedRules.length === 0) {
248
+ return [];
249
+ }
245
250
  const value = this.getFieldValue(name);
246
251
  for (const rule of mergedRules) {
247
252
  const result = await validate(rule, value);
@@ -9,6 +9,7 @@
9
9
  padding: var(--yee-input-padding, 2px 8px);
10
10
  font-size: var(--yee-input-font-size);
11
11
  line-height: var(--yee-input-line-height);
12
+ background-color: var(--yee-input-content-bg-color);
12
13
  border: var(--yee-input-border-width) solid var(--yee-input-border-color);
13
14
  border-radius: var(--yee-input-border-radius, 4px);
14
15
  font-family: var(--yee-font-family);
@@ -81,7 +81,7 @@ export interface SelectProps
81
81
  /**
82
82
  * Dropdown options
83
83
  */
84
- options: Array<Option>;
84
+ options?: Array<Option>;
85
85
  /**
86
86
  * Selection mode
87
87
  */
@@ -5,6 +5,7 @@
5
5
  box-sizing: border-box;
6
6
  width: 100%;
7
7
  padding: var(--yee-selector-padding);
8
+ background-color: var(--yee-input-content-bg-color);
8
9
  border: var(--yee-selector-border-width) solid var(--yee-input-border-color);
9
10
  border-radius: var(--yee-selector-border-radius);
10
11
 
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  const TriangleArrow = ({
4
4
  direction = 'down',
5
5
  className,
6
- size = 10,
6
+ size = 8,
7
7
  ...props
8
8
  }: {
9
9
  direction?: 'up' | 'down' | 'left' | 'right';
@@ -1,5 +1,6 @@
1
1
  import clsx from 'clsx';
2
2
  import React, { useContext } from 'react';
3
+ import { useLocale } from '../../locale';
3
4
  import Tooltip from '../../Tooltip';
4
5
  import ArrowIcon from '../icon/arrow-icon';
5
6
  import { HeadCellProps } from '../interface';
@@ -35,6 +36,8 @@ const HeadCell: React.FC<HeadCellProps> = (props) => {
35
36
  } = props;
36
37
 
37
38
  const { prefixCls } = useContext(TableCtx);
39
+ const { locale } = useLocale();
40
+ const { table: tableLocale } = locale;
38
41
 
39
42
  const showFilter = !!filter;
40
43
 
@@ -80,11 +83,11 @@ const HeadCell: React.FC<HeadCellProps> = (props) => {
80
83
  if (showSorterTooltip && sorter) {
81
84
  let title = '';
82
85
  if (displaySortOrder === 0) {
83
- title = 'Click to sort ascending';
86
+ title = tableLocale.sortAscending || 'Click to sort ascending';
84
87
  } else if (displaySortOrder === 1) {
85
- title = 'Click to sort descending';
88
+ title = tableLocale.sortDescending || 'Click to sort descending';
86
89
  } else if (displaySortOrder === 2) {
87
- title = 'Click to cancel sorting';
90
+ title = tableLocale.sortCancel || 'Click to cancel sorting';
88
91
  }
89
92
 
90
93
  return <Tooltip title={title}>{trigger}</Tooltip>;
@@ -66,6 +66,7 @@
66
66
  padding: 4px 8px;
67
67
  font-size: var(--yee-textarea-font-size);
68
68
  line-height: var(--yee-textarea-line-height);
69
+ background-color: var(--yee-input-content-bg-color);
69
70
  border-radius: var(--yee-textarea-border-radius, 4px);
70
71
  font-family: var(--yee-font-family);
71
72
  width: 100%;
@@ -9,6 +9,7 @@
9
9
  width: var(--yee-verification-code-item-width);
10
10
  height: var(--yee-verification-code-item-height);
11
11
  text-align: center;
12
+ background-color: var(--yee-input-content-bg-color);
12
13
  border: var(--yee-verification-code-item-border-width) solid
13
14
  var(--yee-input-border-color);
14
15
  border-radius: var(--yee-verification-code-item-border-radius);
@@ -4,19 +4,33 @@ export type CountdownOptions = {
4
4
  duration: number;
5
5
  interval?: number;
6
6
  onComplete?: () => void;
7
+ resetKey?: string | number;
7
8
  };
8
9
 
9
10
  export default function useCountdown({
10
11
  duration,
11
12
  interval = 100,
12
13
  onComplete,
14
+ resetKey,
13
15
  }: CountdownOptions) {
14
- const [remaining, setRemaining] = useState(duration);
16
+ const initialRemaining = Math.max(0, duration);
17
+ const [remaining, setRemaining] = useState(initialRemaining);
15
18
  const [isPaused, setIsPaused] = useState(false);
16
19
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
17
- const remainingRef = useRef(duration);
20
+ const remainingRef = useRef(initialRemaining);
21
+ const isPausedRef = useRef(false);
18
22
  const onCompleteRef = useRef(onComplete);
23
+ const identityRef = useRef({ duration, resetKey });
24
+ const epochRef = useRef(0);
25
+
19
26
  onCompleteRef.current = onComplete;
27
+ if (
28
+ identityRef.current.duration !== duration ||
29
+ identityRef.current.resetKey !== resetKey
30
+ ) {
31
+ identityRef.current = { duration, resetKey };
32
+ epochRef.current += 1;
33
+ }
20
34
 
21
35
  const clear = useCallback(() => {
22
36
  if (timerRef.current !== null) {
@@ -25,41 +39,58 @@ export default function useCountdown({
25
39
  }
26
40
  }, []);
27
41
 
28
- const tick = useCallback(() => {
29
- const next = Math.max(0, remainingRef.current - interval);
30
- remainingRef.current = next;
31
- setRemaining(next);
42
+ const tick = useCallback(
43
+ (epoch: number) => {
44
+ if (epoch !== epochRef.current) {
45
+ return;
46
+ }
32
47
 
33
- if (next <= 0) {
34
- onCompleteRef.current?.();
35
- } else {
36
- timerRef.current = setTimeout(tick, interval);
37
- }
38
- }, [interval]);
48
+ const next = Math.max(0, remainingRef.current - interval);
49
+ remainingRef.current = next;
50
+ setRemaining(next);
51
+
52
+ if (next <= 0) {
53
+ onCompleteRef.current?.();
54
+ } else {
55
+ timerRef.current = setTimeout(() => tick(epoch), interval);
56
+ }
57
+ },
58
+ [interval],
59
+ );
39
60
 
40
61
  const start = useCallback(() => {
41
62
  clear();
42
- timerRef.current = setTimeout(tick, interval);
63
+ if (remainingRef.current > 0 && !isPausedRef.current) {
64
+ const epoch = epochRef.current;
65
+ timerRef.current = setTimeout(() => tick(epoch), interval);
66
+ }
43
67
  }, [clear, interval, tick]);
44
68
 
45
69
  useEffect(() => {
46
- remainingRef.current = duration;
47
- setRemaining(duration);
48
- setIsPaused(false);
49
- start();
70
+ clear();
71
+ const nextRemaining = Math.max(0, duration);
72
+ remainingRef.current = nextRemaining;
73
+ setRemaining(nextRemaining);
74
+
75
+ if (nextRemaining > 0 && !isPausedRef.current) {
76
+ start();
77
+ }
78
+
50
79
  return clear;
51
- // eslint-disable-next-line react-hooks/exhaustive-deps
52
- }, [duration]);
80
+ }, [clear, duration, resetKey, start]);
53
81
 
54
82
  const onPause = useCallback(() => {
83
+ isPausedRef.current = true;
55
84
  clear();
56
85
  setIsPaused(true);
57
86
  }, [clear]);
58
87
 
59
88
  const onResume = useCallback(() => {
60
- if (remainingRef.current <= 0) return;
89
+ isPausedRef.current = false;
61
90
  setIsPaused(false);
62
- start();
91
+ if (remainingRef.current > 0) {
92
+ start();
93
+ }
63
94
  }, [start]);
64
95
 
65
96
  return { remaining, isPaused, onPause, onResume };
@@ -76,6 +76,9 @@ export interface LocaleTable {
76
76
  selectionAll?: string; // "Select all on current page"
77
77
  selectionInvert?: string; // "Invert selection on current page"
78
78
  selectionNone?: string; // "Clear selection on current page"
79
+ sortAscending?: string; // "Click to sort ascending"
80
+ sortDescending?: string; // "Click to sort descending"
81
+ sortCancel?: string; // "Click to cancel sorting"
79
82
  }
80
83
 
81
84
  // Pagination component
@@ -102,6 +102,9 @@ const locale: Locale = {
102
102
  selectionAll: 'Select All on Page',
103
103
  selectionInvert: 'Invert Selection on Page',
104
104
  selectionNone: 'Clear Selection on Page',
105
+ sortAscending: 'Click to sort ascending',
106
+ sortDescending: 'Click to sort descending',
107
+ sortCancel: 'Click to cancel sorting',
105
108
  },
106
109
 
107
110
  pagination: {
@@ -102,6 +102,9 @@ const locale: Locale = {
102
102
  selectionAll: 'ページ内すべて選択',
103
103
  selectionInvert: 'ページ内選択を反転',
104
104
  selectionNone: 'ページ内選択をクリア',
105
+ sortAscending: 'クリックで昇順に並べ替え',
106
+ sortDescending: 'クリックで降順に並べ替え',
107
+ sortCancel: 'クリックで並べ替えを解除',
105
108
  },
106
109
 
107
110
  pagination: {
@@ -93,6 +93,9 @@ const locale: Locale = {
93
93
  selectionAll: '全选当页',
94
94
  selectionInvert: '反选当页',
95
95
  selectionNone: '清空当页选择',
96
+ sortAscending: '点击升序排列',
97
+ sortDescending: '点击降序排列',
98
+ sortCancel: '点击取消排序',
96
99
  },
97
100
 
98
101
  pagination: {
@@ -93,6 +93,9 @@ const locale: Locale = {
93
93
  selectionAll: '全選當頁',
94
94
  selectionInvert: '反選當頁',
95
95
  selectionNone: '清空當頁選擇',
96
+ sortAscending: '點擊升序排列',
97
+ sortDescending: '點擊降序排列',
98
+ sortCancel: '點擊取消排序',
96
99
  },
97
100
 
98
101
  pagination: {
@@ -6,7 +6,7 @@ const BaseDemo = () => {
6
6
  const showMessage = (type: string) => {
7
7
  switch (type) {
8
8
  case 'info':
9
- messageApi.open('This is an info message');
9
+ messageApi.open({content: 'This is an info message', duration: 0});
10
10
  break;
11
11
  case 'success':
12
12
  message.success('This is a success message');
@@ -1,4 +1,4 @@
1
1
  import { message } from './wrapper';
2
- export type { MessageConfig } from './interface';
2
+ export type { MessageApi, MessageClose, MessageConfig } from './interface';
3
3
  export * from './message-provider';
4
4
  export default message;
@@ -1,8 +1,9 @@
1
1
  export type MessageConfig = {
2
- /**
3
- * test identifier
4
- */
5
- 'data-testid'?: string;
2
+ [dataAttribute: `data-${string}`]:
3
+ | string
4
+ | number
5
+ | boolean
6
+ | undefined;
6
7
  /**
7
8
  * Custom prefix class name
8
9
  */
@@ -20,8 +21,8 @@ export type MessageConfig = {
20
21
  */
21
22
  content: React.ReactNode;
22
23
  /**
23
- * Auto close time (ms), 0 means no auto close
24
- * @default 3000
24
+ * Auto close time (seconds), 0 means no auto close
25
+ * @default 3
25
26
  */
26
27
  duration?: number;
27
28
  /**
@@ -42,6 +43,19 @@ export type MessageConfig = {
42
43
  onClose?: () => void;
43
44
  };
44
45
 
46
+ export type MessageClose = () => void;
47
+
48
+ export interface MessageApi {
49
+ open: (config: string | MessageConfig) => MessageClose;
50
+ info: (config: string | MessageConfig) => MessageClose;
51
+ success: (config: string | MessageConfig) => MessageClose;
52
+ warning: (config: string | MessageConfig) => MessageClose;
53
+ error: (config: string | MessageConfig) => MessageClose;
54
+ loading: (config: string | MessageConfig) => MessageClose;
55
+ destroy: (key: string | number) => void;
56
+ clear: () => void;
57
+ }
58
+
45
59
  // With status
46
60
  export type WrapperedMessageConfig = MessageConfig & {
47
61
  status?: 'info' | 'success' | 'warning' | 'error' | 'loading';
@@ -50,11 +64,12 @@ export type WrapperedMessageConfig = MessageConfig & {
50
64
  // With key
51
65
  export type MessageType = WrapperedMessageConfig & {
52
66
  key: string | number;
67
+ timerGeneration: number;
53
68
  };
54
69
 
55
70
  export interface MessageProps extends MessageType {
56
71
  /**
57
72
  * Destroy component callback
58
73
  */
59
- onDestroy?: (key: string | number) => void;
74
+ onDestroy?: (key: string | number, timerGeneration?: number) => void;
60
75
  }
@@ -1,7 +1,8 @@
1
1
  import React, { createContext, useContext } from 'react';
2
+ import type { MessageApi } from './interface';
2
3
  import useMessage from './use-message';
3
4
 
4
- const MessageContext = createContext<any>({});
5
+ const MessageContext = createContext<MessageApi | undefined>(undefined);
5
6
 
6
7
  export function MessageProvider({ children }: { children: React.ReactNode }) {
7
8
  const { messageApi, messageHolder } = useMessage();
@@ -14,7 +15,7 @@ export function MessageProvider({ children }: { children: React.ReactNode }) {
14
15
  );
15
16
  }
16
17
 
17
- export function useGlobalMessage() {
18
+ export function useGlobalMessage(): MessageApi {
18
19
  const context = useContext(MessageContext);
19
20
 
20
21
  if (context === undefined) {
@@ -0,0 +1,283 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, render, renderHook, screen } from '@testing-library/react';
4
+ import React from 'react';
5
+ import { renderToString } from 'react-dom/server';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import type { MessageApi, MessageClose } from './interface';
8
+ import { useGlobalMessage } from './message-provider';
9
+ import useMessage from './use-message';
10
+ import messageWrapper, { message } from './wrapper';
11
+
12
+ let currentApi: MessageApi;
13
+
14
+ function MessageHarness() {
15
+ const { messageApi, messageHolder } = useMessage();
16
+ currentApi = messageApi;
17
+ return <>{messageHolder}</>;
18
+ }
19
+
20
+ afterEach(() => {
21
+ cleanup();
22
+ messageWrapper.unmount();
23
+ vi.useRealTimers();
24
+ vi.unstubAllGlobals();
25
+ });
26
+
27
+ describe('message', () => {
28
+ it('renders safely without a document during SSR', () => {
29
+ vi.stubGlobal('document', undefined);
30
+
31
+ expect(() => renderToString(<MessageHarness />)).not.toThrow();
32
+ expect(() => message.info('server message')).not.toThrow();
33
+ });
34
+
35
+ it('keeps the hook API reference stable across message updates', () => {
36
+ render(<MessageHarness />);
37
+ const initialApi = currentApi;
38
+
39
+ act(() => {
40
+ currentApi.info({ content: 'saved', duration: 0 });
41
+ });
42
+
43
+ expect(currentApi).toBe(initialApi);
44
+ });
45
+
46
+ it('does not restart existing timers when another message is added', () => {
47
+ vi.useFakeTimers();
48
+ const firstOnClose = vi.fn();
49
+ render(<MessageHarness />);
50
+
51
+ act(() => {
52
+ currentApi.info({
53
+ content: 'first',
54
+ duration: 3,
55
+ onClose: firstOnClose,
56
+ });
57
+ });
58
+ act(() => {
59
+ vi.advanceTimersByTime(2000);
60
+ });
61
+ act(() => {
62
+ currentApi.info({ content: 'second', duration: 3 });
63
+ });
64
+ act(() => {
65
+ vi.advanceTimersByTime(1000);
66
+ });
67
+
68
+ expect(firstOnClose).toHaveBeenCalledTimes(1);
69
+ expect(screen.queryByText('first')).toBeNull();
70
+ expect(screen.queryByText('second')).not.toBeNull();
71
+ });
72
+
73
+ it('does not let an old timer close a reused key in the same batch', () => {
74
+ vi.useFakeTimers();
75
+ const secondOnClose = vi.fn();
76
+ render(<MessageHarness />);
77
+
78
+ act(() => {
79
+ currentApi.info({ key: 'timer', content: 'first', duration: 3 });
80
+ });
81
+ act(() => {
82
+ vi.advanceTimersByTime(2000);
83
+ });
84
+ act(() => {
85
+ currentApi.destroy('timer');
86
+ currentApi.info({
87
+ key: 'timer',
88
+ content: 'second',
89
+ duration: 3,
90
+ onClose: secondOnClose,
91
+ });
92
+ });
93
+ act(() => {
94
+ vi.advanceTimersByTime(1000);
95
+ });
96
+
97
+ expect(screen.queryByText('second')).not.toBeNull();
98
+ expect(secondOnClose).not.toHaveBeenCalled();
99
+
100
+ act(() => {
101
+ vi.advanceTimersByTime(2000);
102
+ });
103
+
104
+ expect(screen.queryByText('second')).toBeNull();
105
+ expect(secondOnClose).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it('restarts the timer when an existing key is updated', () => {
109
+ vi.useFakeTimers();
110
+ const onClose = vi.fn();
111
+ render(<MessageHarness />);
112
+
113
+ act(() => {
114
+ currentApi.info({
115
+ key: 'updating-timer',
116
+ content: 'working',
117
+ duration: 3,
118
+ onClose,
119
+ });
120
+ });
121
+ act(() => {
122
+ vi.advanceTimersByTime(2000);
123
+ });
124
+ act(() => {
125
+ currentApi.info({
126
+ key: 'updating-timer',
127
+ content: 'finished',
128
+ duration: 3,
129
+ onClose,
130
+ });
131
+ });
132
+ act(() => {
133
+ vi.advanceTimersByTime(1000);
134
+ });
135
+
136
+ expect(screen.queryByText('finished')).not.toBeNull();
137
+ expect(onClose).not.toHaveBeenCalled();
138
+
139
+ act(() => {
140
+ vi.advanceTimersByTime(2000);
141
+ });
142
+
143
+ expect(screen.queryByText('finished')).toBeNull();
144
+ expect(onClose).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('updates duplicate keys and closes the current message with its handle', () => {
148
+ const firstOnClose = vi.fn();
149
+ const updatedOnClose = vi.fn();
150
+ let close!: MessageClose;
151
+ render(<MessageHarness />);
152
+
153
+ act(() => {
154
+ close = currentApi.info({
155
+ key: 'job',
156
+ content: 'working',
157
+ duration: 0,
158
+ onClose: firstOnClose,
159
+ });
160
+ currentApi.success({
161
+ key: 'job',
162
+ content: 'finished',
163
+ duration: 0,
164
+ onClose: updatedOnClose,
165
+ });
166
+ });
167
+
168
+ expect(screen.queryByText('working')).toBeNull();
169
+ expect(screen.queryByText('finished')).not.toBeNull();
170
+ expect(document.querySelectorAll('.yee-message')).toHaveLength(1);
171
+
172
+ act(() => close());
173
+
174
+ expect(screen.queryByText('finished')).toBeNull();
175
+ expect(firstOnClose).not.toHaveBeenCalled();
176
+ expect(updatedOnClose).toHaveBeenCalledTimes(1);
177
+ });
178
+
179
+ it('does not let stale hook close handles close a reused key', () => {
180
+ let staleClose!: MessageClose;
181
+ render(<MessageHarness />);
182
+
183
+ act(() => {
184
+ staleClose = currentApi.info({
185
+ key: 'reused',
186
+ content: 'first generation',
187
+ duration: 0,
188
+ });
189
+ staleClose();
190
+ currentApi.info({
191
+ key: 'reused',
192
+ content: 'second generation',
193
+ duration: 0,
194
+ });
195
+ staleClose();
196
+ });
197
+
198
+ expect(screen.queryByText('second generation')).not.toBeNull();
199
+ });
200
+
201
+ it('does not let stale global close handles close a reused key', () => {
202
+ let staleClose!: MessageClose;
203
+
204
+ act(() => {
205
+ staleClose = message.info({
206
+ key: 'global-reused',
207
+ content: 'first generation',
208
+ duration: 0,
209
+ });
210
+ staleClose();
211
+ message.info({
212
+ key: 'global-reused',
213
+ content: 'second generation',
214
+ duration: 0,
215
+ });
216
+ staleClose();
217
+ });
218
+
219
+ expect(screen.queryByText('second generation')).not.toBeNull();
220
+ act(() => message.destroy('global-reused'));
221
+ });
222
+
223
+ it('calls onClose when clearing hook messages', () => {
224
+ const firstOnClose = vi.fn();
225
+ const secondOnClose = vi.fn();
226
+ render(<MessageHarness />);
227
+
228
+ act(() => {
229
+ currentApi.info({ content: 'first', duration: 0, onClose: firstOnClose });
230
+ currentApi.info({ content: 'second', duration: 0, onClose: secondOnClose });
231
+ currentApi.clear();
232
+ });
233
+
234
+ expect(firstOnClose).toHaveBeenCalledTimes(1);
235
+ expect(secondOnClose).toHaveBeenCalledTimes(1);
236
+ });
237
+
238
+ it('preserves a falsy global key without mutating the config', () => {
239
+ const onClose = vi.fn();
240
+ const config = { key: 0, content: 'zero', duration: 0, onClose };
241
+
242
+ act(() => {
243
+ message.open(config);
244
+ });
245
+
246
+ expect(config.key).toBe(0);
247
+ expect(screen.queryByText('zero')).not.toBeNull();
248
+
249
+ act(() => {
250
+ message.destroy(0);
251
+ });
252
+
253
+ expect(screen.queryByText('zero')).toBeNull();
254
+ expect(onClose).toHaveBeenCalledTimes(1);
255
+ });
256
+
257
+ it('throws a clear error outside MessageProvider', () => {
258
+ expect(() => renderHook(() => useGlobalMessage())).toThrow(
259
+ 'useGlobalMessage must be used within MessageProvider',
260
+ );
261
+ });
262
+
263
+ it('forwards data attributes and exposes live-region semantics', () => {
264
+ render(<MessageHarness />);
265
+
266
+ act(() => {
267
+ currentApi.error({
268
+ content: <div>failed</div>,
269
+ duration: 0,
270
+ 'data-track-id': 'failure-message',
271
+ });
272
+ });
273
+
274
+ const element = screen.getByText('failed').closest('.yee-message');
275
+ expect(element?.getAttribute('data-track-id')).toBe('failure-message');
276
+ expect(element?.getAttribute('role')).toBe('alert');
277
+ expect(element?.getAttribute('aria-live')).toBe('assertive');
278
+ expect(element?.querySelector('.yee-message-content')?.tagName).toBe('DIV');
279
+ expect(element?.querySelector('.yee-message-icon')?.getAttribute('aria-hidden')).toBe(
280
+ 'true',
281
+ );
282
+ });
283
+ });