@charcoal-ui/react 6.0.1 → 6.1.0-beta.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.
Files changed (60) hide show
  1. package/dist/components/Notification/NotificationItem.cjs +30 -0
  2. package/dist/components/Notification/NotificationItem.cjs.map +1 -0
  3. package/dist/components/Notification/NotificationItem.d.ts +15 -0
  4. package/dist/components/Notification/NotificationItem.d.ts.map +1 -0
  5. package/dist/components/Notification/NotificationItem.js +30 -0
  6. package/dist/components/Notification/NotificationItem.js.map +1 -0
  7. package/dist/components/Notification/NotificationRegion.cjs +50 -0
  8. package/dist/components/Notification/NotificationRegion.cjs.map +1 -0
  9. package/dist/components/Notification/NotificationRegion.d.ts +14 -0
  10. package/dist/components/Notification/NotificationRegion.d.ts.map +1 -0
  11. package/dist/components/Notification/NotificationRegion.js +50 -0
  12. package/dist/components/Notification/NotificationRegion.js.map +1 -0
  13. package/dist/components/Notification/Snackbar/index.d.ts +45 -0
  14. package/dist/components/Notification/Snackbar/index.d.ts.map +1 -0
  15. package/dist/components/Notification/Snackbar/index2.cjs +99 -0
  16. package/dist/components/Notification/Snackbar/index2.cjs.map +1 -0
  17. package/dist/components/Notification/Snackbar/index2.js +94 -0
  18. package/dist/components/Notification/Snackbar/index2.js.map +1 -0
  19. package/dist/components/Notification/Toast/index.d.ts +28 -0
  20. package/dist/components/Notification/Toast/index.d.ts.map +1 -0
  21. package/dist/components/Notification/Toast/index2.cjs +86 -0
  22. package/dist/components/Notification/Toast/index2.cjs.map +1 -0
  23. package/dist/components/Notification/Toast/index2.js +81 -0
  24. package/dist/components/Notification/Toast/index2.js.map +1 -0
  25. package/dist/components/Notification/types.d.ts +27 -0
  26. package/dist/components/Notification/types.d.ts.map +1 -0
  27. package/dist/components/Notification/useNotificationQueue.cjs +155 -0
  28. package/dist/components/Notification/useNotificationQueue.cjs.map +1 -0
  29. package/dist/components/Notification/useNotificationQueue.d.ts +16 -0
  30. package/dist/components/Notification/useNotificationQueue.d.ts.map +1 -0
  31. package/dist/components/Notification/useNotificationQueue.js +155 -0
  32. package/dist/components/Notification/useNotificationQueue.js.map +1 -0
  33. package/dist/components/Pagination/index2.js +52 -60
  34. package/dist/components/Pagination/index2.js.map +1 -1
  35. package/dist/index.cjs +6 -0
  36. package/dist/index.css +1608 -1432
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -1
  40. package/dist/layered.css +1608 -1432
  41. package/dist/layered.css.map +1 -1
  42. package/package.json +5 -5
  43. package/src/__tests__/css-output.test.ts +39 -6
  44. package/src/components/Notification/NotificationItem.tsx +51 -0
  45. package/src/components/Notification/NotificationRegion.tsx +76 -0
  46. package/src/components/Notification/Snackbar/__snapshots__/index.css.snap +69 -0
  47. package/src/components/Notification/Snackbar/index.css +68 -0
  48. package/src/components/Notification/Snackbar/index.story.tsx +213 -0
  49. package/src/components/Notification/Snackbar/index.test.tsx +344 -0
  50. package/src/components/Notification/Snackbar/index.tsx +131 -0
  51. package/src/components/Notification/Toast/__snapshots__/index.css.snap +62 -0
  52. package/src/components/Notification/Toast/index.css +61 -0
  53. package/src/components/Notification/Toast/index.story.tsx +174 -0
  54. package/src/components/Notification/Toast/index.test.tsx +263 -0
  55. package/src/components/Notification/Toast/index.tsx +86 -0
  56. package/src/components/Notification/__snapshots__/layout.css.snap +47 -0
  57. package/src/components/Notification/layout.css +46 -0
  58. package/src/components/Notification/types.ts +28 -0
  59. package/src/components/Notification/useNotificationQueue.ts +235 -0
  60. package/src/index.ts +19 -0
@@ -0,0 +1,131 @@
1
+ import '../layout.css'
2
+ import './index.css'
3
+
4
+ import { forwardRef, useImperativeHandle, useRef } from 'react'
5
+ import type { ReactNode } from 'react'
6
+ import { NotificationItem } from '../NotificationItem'
7
+ import { NotificationRegion } from '../NotificationRegion'
8
+ import { useNotificationQueue } from '../useNotificationQueue'
9
+ import type { NotificationProps, Position } from '../types'
10
+
11
+ export type SnackbarCloseReason =
12
+ 'timeout' | 'replaced' | 'action' | 'close' | 'unmounted'
13
+
14
+ export type ShowSnackbarOptions = {
15
+ /**
16
+ * Snackbar の右側に表示するアクション
17
+ */
18
+ action?: ReactNode
19
+ /**
20
+ * Snackbar が閉じるときに呼び出される
21
+ */
22
+ onClose?: (reason: SnackbarCloseReason) => void
23
+ }
24
+
25
+ export type SnackbarHandler = {
26
+ show: (message: ReactNode, options?: ShowSnackbarOptions) => void
27
+ }
28
+
29
+ export type SnackbarProps = Omit<NotificationProps, 'position'> & {
30
+ /**
31
+ * Snackbar の表示位置。ボタン付きの場合は `bottom` に固定される
32
+ * @default 'bottom'
33
+ */
34
+ position?: Position
35
+ /**
36
+ * 暗い背景色
37
+ * @default false
38
+ */
39
+ dim?: boolean
40
+ }
41
+
42
+ type SnackbarContent = {
43
+ message: ReactNode
44
+ action?: ReactNode
45
+ }
46
+
47
+ const Snackbar = forwardRef<SnackbarHandler, SnackbarProps>(function Snackbar(
48
+ { position = 'bottom', dim = false, duration, order, ...regionProps },
49
+ ref,
50
+ ) {
51
+ 'use memo'
52
+
53
+ const { state, itemRef, enqueue, close, onHoverStart, onHoverEnd } =
54
+ useNotificationQueue<SnackbarContent, SnackbarCloseReason>('snackbar', {
55
+ duration,
56
+ order,
57
+ timeoutReason: 'timeout',
58
+ unmountedReason: 'unmounted',
59
+ })
60
+
61
+ function show(message: ReactNode, options: ShowSnackbarOptions = {}) {
62
+ enqueue(
63
+ {
64
+ message,
65
+ action: options.action,
66
+ },
67
+ options.onClose,
68
+ )
69
+ }
70
+
71
+ useImperativeHandle(ref, () => ({ show }))
72
+
73
+ // アクション付きの Snackbar は常に下部に表示される
74
+ const hasAction = state.visibleToasts.some(
75
+ (toast) => toast.content.action !== undefined,
76
+ )
77
+ const effectivePosition = hasAction ? 'bottom' : position
78
+
79
+ return (
80
+ <NotificationRegion
81
+ name="snackbar"
82
+ state={state}
83
+ position={effectivePosition}
84
+ {...regionProps}
85
+ >
86
+ {state.visibleToasts.map((toast) => (
87
+ <NotificationItem
88
+ key={toast.key}
89
+ name="snackbar"
90
+ toast={toast}
91
+ state={state}
92
+ itemRef={itemRef}
93
+ onHoverStart={onHoverStart}
94
+ onHoverEnd={onHoverEnd}
95
+ data-dim={dim}
96
+ data-with-action={toast.content.action !== undefined}
97
+ >
98
+ {toast.content.action !== undefined && (
99
+ <SnackbarAction
100
+ action={toast.content.action}
101
+ onClick={() => close(toast.key, 'action')}
102
+ />
103
+ )}
104
+ </NotificationItem>
105
+ ))}
106
+ </NotificationRegion>
107
+ )
108
+ })
109
+
110
+ export default Snackbar
111
+
112
+ export function useSnackbar(props: SnackbarProps = {}) {
113
+ 'use memo'
114
+
115
+ const snackbarHandlerRef = useRef<SnackbarHandler>(null)
116
+ const element = <Snackbar ref={snackbarHandlerRef} {...props} />
117
+ function show(message: ReactNode, options?: ShowSnackbarOptions) {
118
+ snackbarHandlerRef.current?.show(message, options)
119
+ }
120
+ return [element, show] as const
121
+ }
122
+
123
+ function SnackbarAction({
124
+ action,
125
+ onClick,
126
+ }: {
127
+ action: ReactNode
128
+ onClick: () => void
129
+ }) {
130
+ return <div onClick={onClick}>{action}</div>
131
+ }
@@ -0,0 +1,62 @@
1
+ .charcoal-toast-region {
2
+ --charcoal-toast-offset: 16px;
3
+ }
4
+
5
+ .charcoal-toast-region[data-position='top'] {
6
+ --charcoal-toast-enter-offset: calc(-1 * var(--charcoal-space-30));
7
+ top: calc(
8
+ env(safe-area-inset-top, 0px) + max(var(--charcoal-toast-offset), 16px)
9
+ );
10
+ }
11
+
12
+ .charcoal-toast-region[data-position='bottom'] {
13
+ --charcoal-toast-enter-offset: var(--charcoal-space-30);
14
+ bottom: calc(
15
+ env(safe-area-inset-bottom, 0px) + max(var(--charcoal-toast-offset), 16px)
16
+ );
17
+ }
18
+
19
+ .charcoal-toast {
20
+ min-height: 40px;
21
+ padding: var(--charcoal-space-20) var(--charcoal-space-40);
22
+ color: var(--charcoal-color-text-on-positive-default);
23
+ border: var(--charcoal-border-width-m) solid var(--charcoal-color-border-hud);
24
+ animation: charcoal-toast-enter 0.3s ease-out both;
25
+ }
26
+
27
+ .charcoal-toast[data-exiting='true'] {
28
+ animation: charcoal-toast-exit 0.3s ease-out both;
29
+ }
30
+
31
+ .charcoal-toast[data-type='success'] {
32
+ background-color: var(--charcoal-color-container-positive-default);
33
+ }
34
+
35
+ .charcoal-toast[data-type='error'] {
36
+ background-color: var(--charcoal-color-container-negative-default);
37
+ }
38
+
39
+ @keyframes charcoal-toast-enter {
40
+
41
+ from {
42
+ opacity: 0;
43
+ transform: translateY(var(--charcoal-toast-enter-offset));
44
+ }
45
+
46
+ to {
47
+ opacity: 1;
48
+ transform: translateY(0);
49
+ }
50
+ }
51
+
52
+ @keyframes charcoal-toast-exit {
53
+
54
+ from {
55
+ opacity: 1;
56
+ }
57
+
58
+ to {
59
+ opacity: 0;
60
+ }
61
+ }
62
+
@@ -0,0 +1,61 @@
1
+ .charcoal-toast-region {
2
+ --charcoal-toast-offset: 16px;
3
+
4
+ &[data-position='top'] {
5
+ --charcoal-toast-enter-offset: calc(-1 * var(--charcoal-space-30));
6
+
7
+ top: calc(
8
+ env(safe-area-inset-top, 0px) + max(var(--charcoal-toast-offset), 16px)
9
+ );
10
+ }
11
+
12
+ &[data-position='bottom'] {
13
+ --charcoal-toast-enter-offset: var(--charcoal-space-30);
14
+
15
+ bottom: calc(
16
+ env(safe-area-inset-bottom, 0px) + max(var(--charcoal-toast-offset), 16px)
17
+ );
18
+ }
19
+ }
20
+
21
+ .charcoal-toast {
22
+ min-height: 40px;
23
+ padding: var(--charcoal-space-20) var(--charcoal-space-40);
24
+ color: var(--charcoal-color-text-on-positive-default);
25
+ border: var(--charcoal-border-width-m) solid var(--charcoal-color-border-hud);
26
+ animation: charcoal-toast-enter 0.3s ease-out both;
27
+
28
+ &[data-exiting='true'] {
29
+ animation: charcoal-toast-exit 0.3s ease-out both;
30
+ }
31
+
32
+ &[data-type='success'] {
33
+ background-color: var(--charcoal-color-container-positive-default);
34
+ }
35
+
36
+ &[data-type='error'] {
37
+ background-color: var(--charcoal-color-container-negative-default);
38
+ }
39
+ }
40
+
41
+ @keyframes charcoal-toast-enter {
42
+ from {
43
+ opacity: 0;
44
+ transform: translateY(var(--charcoal-toast-enter-offset));
45
+ }
46
+
47
+ to {
48
+ opacity: 1;
49
+ transform: translateY(0);
50
+ }
51
+ }
52
+
53
+ @keyframes charcoal-toast-exit {
54
+ from {
55
+ opacity: 1;
56
+ }
57
+
58
+ to {
59
+ opacity: 0;
60
+ }
61
+ }
@@ -0,0 +1,174 @@
1
+ import { useEffect, type ReactNode } from 'react'
2
+ import { Meta, StoryObj } from '@storybook/react-vite'
3
+ import Button from '../../Button'
4
+ import unstable_Toast, {
5
+ useToast as unstable_useToast,
6
+ type ToastProps as unstable_ToastProps,
7
+ type ShowToastOptions as unstable_ShowToastOptions,
8
+ } from '.'
9
+
10
+ const defaultOpen = !!process.env.TEST
11
+
12
+ type ToastStoryArgs = unstable_ToastProps & {
13
+ message: ReactNode
14
+ type: unstable_ShowToastOptions['type']
15
+ }
16
+
17
+ export default {
18
+ title: 'react/unstable_Toast',
19
+ component: unstable_Toast,
20
+ parameters: {
21
+ layout: 'centered',
22
+ tokenVersion: 'v2',
23
+ controls: {
24
+ sort: 'requiredFirst',
25
+ },
26
+ docs: {
27
+ description: {
28
+ component: `同時に表示できるトーストは1つのみです。表示中に別のトーストが発生した場合は、\`order\` に応じてキューに積むか、表示中のトーストを置き換えます。
29
+
30
+ 別々の \`useToast\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。`,
31
+ },
32
+ },
33
+ },
34
+ args: {
35
+ message: '保存しました',
36
+ duration: 5000,
37
+ order: 'queue',
38
+ type: 'success',
39
+ },
40
+ argTypes: {
41
+ position: {
42
+ options: ['top', 'bottom'],
43
+ control: { type: 'inline-radio' },
44
+ table: { category: 'Hook' },
45
+ },
46
+ offset: {
47
+ control: { type: 'number', min: 0, step: 1 },
48
+ table: { category: 'Hook' },
49
+ },
50
+ duration: {
51
+ control: { type: 'number', min: 0, step: 500 },
52
+ description: '表示時間(ミリ秒)',
53
+ table: { category: 'Hook' },
54
+ },
55
+ order: {
56
+ options: ['queue', 'replace'],
57
+ control: { type: 'inline-radio' },
58
+ table: { category: 'Hook' },
59
+ },
60
+ zIndex: {
61
+ control: { type: 'number', min: 0, step: 1 },
62
+ table: { category: 'Hook' },
63
+ },
64
+ className: {
65
+ control: 'text',
66
+ table: { category: 'Hook' },
67
+ },
68
+ portalContainer: {
69
+ control: false,
70
+ table: { category: 'Hook' },
71
+ },
72
+ css: {
73
+ table: { disable: true },
74
+ },
75
+ type: {
76
+ options: ['success', 'error'],
77
+ control: { type: 'inline-radio' },
78
+ table: { category: 'Show' },
79
+ },
80
+ message: {
81
+ control: 'text',
82
+ table: { category: 'Show' },
83
+ },
84
+ },
85
+ render: (args) => <ToastDemo {...args} />,
86
+ } as Meta<ToastStoryArgs>
87
+
88
+ function ToastDemo({ message, type, ...props }: ToastStoryArgs) {
89
+ const [toast, showToast] = unstable_useToast(props)
90
+ const showOptions = {
91
+ type,
92
+ }
93
+
94
+ useEffect(() => {
95
+ if (!defaultOpen) {
96
+ return
97
+ }
98
+ showToast(message, {
99
+ type,
100
+ })
101
+ }, [message, showToast, type])
102
+
103
+ return (
104
+ <>
105
+ {toast}
106
+ <Button
107
+ onClick={() => {
108
+ showToast(message, showOptions)
109
+ }}
110
+ >
111
+ show
112
+ </Button>
113
+ </>
114
+ )
115
+ }
116
+
117
+ export const Default: StoryObj<ToastStoryArgs> = {
118
+ parameters: {
119
+ docs: {
120
+ source: {
121
+ language: 'tsx',
122
+ code: `import { Button, unstable_useToast } from '@charcoal-ui/react'
123
+
124
+ export function Example() {
125
+ const [toast, showToast] = unstable_useToast()
126
+
127
+ return (
128
+ <>
129
+ {toast}
130
+ <Button
131
+ onClick={() => showToast('保存しました', { type: 'success' })}
132
+ >
133
+ 保存
134
+ </Button>
135
+ </>
136
+ )
137
+ }`,
138
+ },
139
+ },
140
+ },
141
+ }
142
+
143
+ export const Error: StoryObj<ToastStoryArgs> = {
144
+ args: {
145
+ type: 'error',
146
+ message: '保存に失敗しました',
147
+ },
148
+ }
149
+
150
+ export const LongMessage: StoryObj<ToastStoryArgs> = {
151
+ args: {
152
+ message:
153
+ '保存した内容はすべての端末に同期され、あとから設定画面で変更できます。長いメッセージは2行を超えると省略されます',
154
+ },
155
+ }
156
+
157
+ export const WithLineBreak: StoryObj<ToastStoryArgs> = {
158
+ args: {
159
+ message: (
160
+ <>
161
+ 保存に失敗しました
162
+ <br />
163
+ 通信環境を確認してください
164
+ </>
165
+ ),
166
+ },
167
+ }
168
+
169
+ export const Bottom: StoryObj<ToastStoryArgs> = {
170
+ args: {
171
+ position: 'bottom',
172
+ message: '下部に表示します',
173
+ },
174
+ }
@@ -0,0 +1,263 @@
1
+ import { createRef, type ComponentProps } from 'react'
2
+ import { render, fireEvent, act, cleanup, screen } from '@testing-library/react'
3
+ import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'
4
+ import Toast, { useToast, type ToastHandler } from '.'
5
+
6
+ function renderToast(props: ComponentProps<typeof Toast> = {}) {
7
+ const ref = createRef<ToastHandler>()
8
+ const result = render(<Toast ref={ref} {...props} />)
9
+ const show: ToastHandler['show'] = (message, options) => {
10
+ act(() => {
11
+ ref.current?.show(message, options)
12
+ })
13
+ act(() => {
14
+ vi.advanceTimersByTime(300)
15
+ })
16
+ }
17
+ return { ...result, show }
18
+ }
19
+
20
+ function finishExitAnimation(message: string) {
21
+ const toast = screen.getByText(message).closest('.charcoal-toast')
22
+ if (toast === null) throw new Error('Toast not found')
23
+ fireEvent(
24
+ toast,
25
+ Object.assign(new Event('animationend', { bubbles: true }), {
26
+ animationName: 'charcoal-toast-exit',
27
+ }),
28
+ )
29
+ }
30
+
31
+ describe('Toast', () => {
32
+ beforeEach(() => {
33
+ vi.useFakeTimers()
34
+ })
35
+
36
+ afterEach(() => {
37
+ cleanup()
38
+ vi.useRealTimers()
39
+ })
40
+
41
+ it('shows a message and hides after 5 seconds', () => {
42
+ const { show } = renderToast()
43
+
44
+ expect(screen.queryByRole('region')).not.toBeInTheDocument()
45
+
46
+ show('保存しました', { type: 'success' })
47
+
48
+ expect(screen.getByText('保存しました')).toBeInTheDocument()
49
+
50
+ act(() => {
51
+ vi.advanceTimersByTime(5000)
52
+ })
53
+
54
+ expect(screen.getByText('保存しました')).toBeInTheDocument()
55
+ expect(
56
+ screen.getByText('保存しました').closest('.charcoal-toast'),
57
+ ).toHaveAttribute('data-exiting', 'true')
58
+
59
+ finishExitAnimation('保存しました')
60
+
61
+ expect(screen.queryByText('保存しました')).not.toBeInTheDocument()
62
+ })
63
+
64
+ it.each([0, -1])('clamps duration to zero: %s', (duration) => {
65
+ const { show } = renderToast({ duration })
66
+
67
+ show('保存しました', { type: 'success' })
68
+ expect(screen.getByText('保存しました')).toBeInTheDocument()
69
+
70
+ act(() => {
71
+ vi.advanceTimersByTime(1)
72
+ })
73
+ expect(
74
+ screen.getByText('保存しました').closest('.charcoal-toast'),
75
+ ).toHaveAttribute('data-exiting', 'true')
76
+
77
+ finishExitAnimation('保存しました')
78
+ expect(screen.queryByText('保存しました')).not.toBeInTheDocument()
79
+ })
80
+
81
+ it.each([
82
+ Number.NaN,
83
+ Number.POSITIVE_INFINITY,
84
+ Number.NEGATIVE_INFINITY,
85
+ 'invalid' as unknown as number,
86
+ ])('falls back to the default duration: %s', (duration) => {
87
+ const { show } = renderToast({ duration })
88
+
89
+ show('保存しました', { type: 'success' })
90
+ act(() => {
91
+ vi.advanceTimersByTime(4999)
92
+ })
93
+ expect(screen.getByText('保存しました')).toBeInTheDocument()
94
+
95
+ act(() => {
96
+ vi.advanceTimersByTime(1)
97
+ })
98
+ expect(
99
+ screen.getByText('保存しました').closest('.charcoal-toast'),
100
+ ).toHaveAttribute('data-exiting', 'true')
101
+ })
102
+
103
+ it('queues the next toast until the previous one closes', async () => {
104
+ const { show } = renderToast()
105
+
106
+ show('first', { type: 'success' })
107
+ show('second', { type: 'error' })
108
+
109
+ expect(screen.getByText('first')).toBeInTheDocument()
110
+ expect(screen.queryByText('second')).not.toBeInTheDocument()
111
+
112
+ act(() => {
113
+ vi.advanceTimersByTime(5000)
114
+ })
115
+ await act(async () => {
116
+ finishExitAnimation('first')
117
+ })
118
+
119
+ expect(screen.queryByText('first')).not.toBeInTheDocument()
120
+ expect(screen.getByText('second')).toBeInTheDocument()
121
+ expect(
122
+ screen.getByText('second').closest('.charcoal-toast'),
123
+ ).toHaveAttribute('data-type', 'error')
124
+ })
125
+
126
+ it('replaces the current toast immediately when requested', () => {
127
+ const { show } = renderToast({ order: 'replace' })
128
+
129
+ show('first', { type: 'success' })
130
+ show('second', { type: 'error' })
131
+
132
+ expect(screen.queryByText('first')).not.toBeInTheDocument()
133
+ expect(screen.getByText('second')).toBeInTheDocument()
134
+ })
135
+
136
+ it('keeps the toast open while hovered after the timer ends, then closes on leave', () => {
137
+ const { show } = renderToast()
138
+
139
+ show('保存しました', { type: 'success' })
140
+ const toast = screen.getByText('保存しました').closest('.charcoal-toast')
141
+ if (toast === null) throw new Error('Toast not found')
142
+
143
+ fireEvent.pointerEnter(toast, { pointerType: 'mouse' })
144
+ act(() => {
145
+ vi.advanceTimersByTime(5000)
146
+ })
147
+ expect(toast).not.toHaveAttribute('data-exiting', 'true')
148
+
149
+ fireEvent.pointerLeave(toast)
150
+ expect(toast).toHaveAttribute('data-exiting', 'true')
151
+ })
152
+
153
+ it('keeps the toast open while focused', () => {
154
+ const { show } = renderToast()
155
+
156
+ show('保存しました', { type: 'success' })
157
+ const toast = screen.getByText('保存しました').closest('.charcoal-toast')
158
+ if (toast === null) throw new Error('Toast not found')
159
+
160
+ fireEvent.keyDown(document, { key: 'F6' })
161
+ expect(screen.getByRole('region')).toHaveFocus()
162
+
163
+ act(() => {
164
+ vi.advanceTimersByTime(5000)
165
+ })
166
+ expect(toast).not.toHaveAttribute('data-exiting', 'true')
167
+
168
+ fireEvent.blur(screen.getByRole('region'))
169
+ act(() => {
170
+ vi.advanceTimersByTime(5000)
171
+ })
172
+ expect(toast).toHaveAttribute('data-exiting', 'true')
173
+ })
174
+
175
+ it('places a toast at the top by default', () => {
176
+ const { show } = renderToast()
177
+
178
+ show('保存しました', { type: 'success' })
179
+
180
+ expect(screen.getByRole('region')).toHaveAttribute('data-position', 'top')
181
+ })
182
+
183
+ it('places a toast at the bottom when requested', () => {
184
+ const { show } = renderToast({ position: 'bottom' })
185
+
186
+ show('保存しました', { type: 'success' })
187
+
188
+ expect(screen.getByRole('region')).toHaveAttribute(
189
+ 'data-position',
190
+ 'bottom',
191
+ )
192
+ })
193
+
194
+ it.each(['success', 'error'] as const)('applies the %s type', (type) => {
195
+ const { show } = renderToast()
196
+
197
+ show('保存しました', { type })
198
+
199
+ expect(
200
+ screen.getByText('保存しました').closest('.charcoal-toast'),
201
+ ).toHaveAttribute('data-type', type)
202
+ })
203
+
204
+ it('supports a custom z-index and portal container', () => {
205
+ const portalContainer = document.createElement('div')
206
+ document.body.append(portalContainer)
207
+ const { show } = renderToast({ zIndex: 30, portalContainer })
208
+
209
+ show('保存しました', { type: 'success' })
210
+
211
+ expect(portalContainer).toContainElement(screen.getByRole('region'))
212
+ expect(screen.getByRole('region')).toHaveStyle({ zIndex: 30 })
213
+
214
+ portalContainer.remove()
215
+ })
216
+
217
+ it('moves focus to the toast region with F6', () => {
218
+ const { show } = renderToast()
219
+
220
+ show('保存しました', { type: 'success' })
221
+
222
+ fireEvent.keyDown(document, { key: 'F6' })
223
+
224
+ expect(screen.getByRole('region')).toHaveFocus()
225
+ })
226
+ })
227
+
228
+ describe('useToast', () => {
229
+ beforeEach(() => {
230
+ vi.useFakeTimers()
231
+ })
232
+
233
+ afterEach(() => {
234
+ cleanup()
235
+ vi.useRealTimers()
236
+ })
237
+
238
+ it('shows a toast via the returned void function', () => {
239
+ let showResult: void | undefined
240
+
241
+ function HookApp() {
242
+ const [toast, show] = useToast()
243
+ return (
244
+ <>
245
+ {toast}
246
+ <button
247
+ type="button"
248
+ onClick={() => {
249
+ showResult = show('hook message', { type: 'success' })
250
+ }}
251
+ >
252
+ open
253
+ </button>
254
+ </>
255
+ )
256
+ }
257
+
258
+ render(<HookApp />)
259
+ fireEvent.click(screen.getByText('open'))
260
+ expect(screen.getByText('hook message')).toBeInTheDocument()
261
+ expect(showResult).toBeUndefined()
262
+ })
263
+ })