@charcoal-ui/react 6.1.0-beta.0 → 6.1.0-beta.2
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/components/Notification/NotificationItem.cjs +30 -0
- package/dist/components/Notification/NotificationItem.cjs.map +1 -0
- package/dist/components/Notification/NotificationItem.d.ts +15 -0
- package/dist/components/Notification/NotificationItem.d.ts.map +1 -0
- package/dist/components/Notification/NotificationItem.js +30 -0
- package/dist/components/Notification/NotificationItem.js.map +1 -0
- package/dist/components/Notification/NotificationRegion.cjs +50 -0
- package/dist/components/Notification/NotificationRegion.cjs.map +1 -0
- package/dist/components/Notification/NotificationRegion.d.ts +14 -0
- package/dist/components/Notification/NotificationRegion.d.ts.map +1 -0
- package/dist/components/Notification/NotificationRegion.js +50 -0
- package/dist/components/Notification/NotificationRegion.js.map +1 -0
- package/dist/components/Notification/Snackbar/index.d.ts +29 -0
- package/dist/components/Notification/Snackbar/index.d.ts.map +1 -0
- package/dist/components/Notification/Snackbar/index2.cjs +79 -0
- package/dist/components/Notification/Snackbar/index2.cjs.map +1 -0
- package/dist/components/Notification/Snackbar/index2.js +78 -0
- package/dist/components/Notification/Snackbar/index2.js.map +1 -0
- package/dist/components/Notification/Toast/index.d.ts +28 -0
- package/dist/components/Notification/Toast/index.d.ts.map +1 -0
- package/dist/components/Notification/Toast/index2.cjs +86 -0
- package/dist/components/Notification/Toast/index2.cjs.map +1 -0
- package/dist/components/Notification/Toast/index2.js +85 -0
- package/dist/components/Notification/Toast/index2.js.map +1 -0
- package/dist/components/Notification/types.d.ts +27 -0
- package/dist/components/Notification/types.d.ts.map +1 -0
- package/dist/components/Notification/useNotificationQueue.cjs +155 -0
- package/dist/components/Notification/useNotificationQueue.cjs.map +1 -0
- package/dist/components/Notification/useNotificationQueue.d.ts +16 -0
- package/dist/components/Notification/useNotificationQueue.d.ts.map +1 -0
- package/dist/components/Notification/useNotificationQueue.js +155 -0
- package/dist/components/Notification/useNotificationQueue.js.map +1 -0
- package/dist/components/Pagination/index2.js +60 -52
- package/dist/components/Pagination/index2.js.map +1 -1
- package/dist/index.cjs +5 -2
- package/dist/index.css +822 -757
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/layered.css +822 -757
- package/dist/layered.css.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/css-output.test.ts +39 -6
- package/src/components/Notification/NotificationItem.tsx +51 -0
- package/src/components/Notification/NotificationRegion.tsx +76 -0
- package/src/components/{Snackbar → Notification/Snackbar}/__snapshots__/index.css.snap +1 -42
- package/src/components/{Snackbar → Notification/Snackbar}/index.css +1 -43
- package/src/components/{Snackbar → Notification/Snackbar}/index.story.tsx +70 -34
- package/src/components/{Snackbar → Notification/Snackbar}/index.test.tsx +99 -18
- package/src/components/Notification/Snackbar/index.tsx +136 -0
- package/src/components/Notification/Toast/__snapshots__/index.css.snap +62 -0
- package/src/components/Notification/Toast/index.css +61 -0
- package/src/components/Notification/Toast/index.story.tsx +177 -0
- package/src/components/Notification/Toast/index.test.tsx +263 -0
- package/src/components/Notification/Toast/index.tsx +86 -0
- package/src/components/Notification/__snapshots__/layout.css.snap +47 -0
- package/src/components/Notification/layout.css +46 -0
- package/src/components/Notification/types.ts +28 -0
- package/src/components/Notification/useNotificationQueue.ts +235 -0
- package/src/index.ts +17 -5
- package/dist/components/Snackbar/index.d.ts +0 -52
- package/dist/components/Snackbar/index.d.ts.map +0 -1
- package/dist/components/Snackbar/index2.cjs +0 -268
- package/dist/components/Snackbar/index2.cjs.map +0 -1
- package/dist/components/Snackbar/index2.js +0 -263
- package/dist/components/Snackbar/index2.js.map +0 -1
- package/src/components/Snackbar/index.tsx +0 -455
|
@@ -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,177 @@
|
|
|
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/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
|
+
position: 'top',
|
|
37
|
+
offset: 16,
|
|
38
|
+
duration: 5000,
|
|
39
|
+
order: 'queue',
|
|
40
|
+
zIndex: 20,
|
|
41
|
+
type: 'success',
|
|
42
|
+
},
|
|
43
|
+
argTypes: {
|
|
44
|
+
position: {
|
|
45
|
+
options: ['top', 'bottom'],
|
|
46
|
+
control: { type: 'inline-radio' },
|
|
47
|
+
table: { category: 'Hook', defaultValue: { summary: "'top'" } },
|
|
48
|
+
},
|
|
49
|
+
offset: {
|
|
50
|
+
control: { type: 'number', min: 0, step: 1 },
|
|
51
|
+
table: { category: 'Hook', defaultValue: { summary: '16' } },
|
|
52
|
+
},
|
|
53
|
+
duration: {
|
|
54
|
+
control: { type: 'number', min: 0, step: 500 },
|
|
55
|
+
description: '表示時間(ミリ秒)',
|
|
56
|
+
table: { category: 'Hook', defaultValue: { summary: '5000' } },
|
|
57
|
+
},
|
|
58
|
+
order: {
|
|
59
|
+
options: ['queue', 'replace'],
|
|
60
|
+
control: { type: 'inline-radio' },
|
|
61
|
+
table: { category: 'Hook', defaultValue: { summary: "'queue'" } },
|
|
62
|
+
},
|
|
63
|
+
zIndex: {
|
|
64
|
+
control: { type: 'number', min: 0, step: 1 },
|
|
65
|
+
table: { category: 'Hook', defaultValue: { summary: '20' } },
|
|
66
|
+
},
|
|
67
|
+
className: {
|
|
68
|
+
control: 'text',
|
|
69
|
+
table: { category: 'Hook' },
|
|
70
|
+
},
|
|
71
|
+
portalContainer: {
|
|
72
|
+
control: false,
|
|
73
|
+
table: { category: 'Hook' },
|
|
74
|
+
},
|
|
75
|
+
css: {
|
|
76
|
+
table: { disable: true },
|
|
77
|
+
},
|
|
78
|
+
type: {
|
|
79
|
+
options: ['success', 'error'],
|
|
80
|
+
control: { type: 'inline-radio' },
|
|
81
|
+
table: { category: 'Show' },
|
|
82
|
+
},
|
|
83
|
+
message: {
|
|
84
|
+
control: 'text',
|
|
85
|
+
table: { category: 'Show' },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
render: (args) => <ToastDemo {...args} />,
|
|
89
|
+
} as Meta<ToastStoryArgs>
|
|
90
|
+
|
|
91
|
+
function ToastDemo({ message, type, ...props }: ToastStoryArgs) {
|
|
92
|
+
const [toast, showToast] = unstable_useToast(props)
|
|
93
|
+
const showOptions = {
|
|
94
|
+
type,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!defaultOpen) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
showToast(message, {
|
|
102
|
+
type,
|
|
103
|
+
})
|
|
104
|
+
}, [message, showToast, type])
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
{toast}
|
|
109
|
+
<Button
|
|
110
|
+
onClick={() => {
|
|
111
|
+
showToast(message, showOptions)
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
show
|
|
115
|
+
</Button>
|
|
116
|
+
</>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const Default: StoryObj<ToastStoryArgs> = {
|
|
121
|
+
parameters: {
|
|
122
|
+
docs: {
|
|
123
|
+
source: {
|
|
124
|
+
language: 'tsx',
|
|
125
|
+
code: `import { Button, unstable_useToast } from '@charcoal-ui/react'
|
|
126
|
+
|
|
127
|
+
export function Example() {
|
|
128
|
+
const [toast, showToast] = unstable_useToast()
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{toast}
|
|
133
|
+
<Button
|
|
134
|
+
onClick={() => showToast('保存しました', { type: 'success' })}
|
|
135
|
+
>
|
|
136
|
+
保存
|
|
137
|
+
</Button>
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}`,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const Error: StoryObj<ToastStoryArgs> = {
|
|
147
|
+
args: {
|
|
148
|
+
type: 'error',
|
|
149
|
+
message: '保存に失敗しました',
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const LongMessage: StoryObj<ToastStoryArgs> = {
|
|
154
|
+
args: {
|
|
155
|
+
message:
|
|
156
|
+
'保存した内容はすべての端末に同期され、あとから設定画面で変更できます。長いメッセージは2行を超えると省略されます',
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const WithLineBreak: StoryObj<ToastStoryArgs> = {
|
|
161
|
+
args: {
|
|
162
|
+
message: (
|
|
163
|
+
<>
|
|
164
|
+
保存に失敗しました
|
|
165
|
+
<br />
|
|
166
|
+
通信環境を確認してください
|
|
167
|
+
</>
|
|
168
|
+
),
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const Bottom: StoryObj<ToastStoryArgs> = {
|
|
173
|
+
args: {
|
|
174
|
+
position: 'bottom',
|
|
175
|
+
message: '下部に表示します',
|
|
176
|
+
},
|
|
177
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
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 ToastType = 'success' | 'error'
|
|
12
|
+
|
|
13
|
+
export type ShowToastOptions = {
|
|
14
|
+
type: ToastType
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ToastHandler = {
|
|
18
|
+
show: (message: ReactNode, options: ShowToastOptions) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ToastProps = Omit<NotificationProps, 'position'> & {
|
|
22
|
+
/**
|
|
23
|
+
* Toast の表示位置
|
|
24
|
+
* @default 'top'
|
|
25
|
+
*/
|
|
26
|
+
position?: Position
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ToastContent = {
|
|
30
|
+
message: ReactNode
|
|
31
|
+
type: ToastType
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const Toast = forwardRef<ToastHandler, ToastProps>(function Toast(
|
|
35
|
+
{ position = 'top', duration, order, ...regionProps },
|
|
36
|
+
ref,
|
|
37
|
+
) {
|
|
38
|
+
'use memo'
|
|
39
|
+
|
|
40
|
+
const { state, itemRef, enqueue, onHoverStart, onHoverEnd } =
|
|
41
|
+
useNotificationQueue<ToastContent>('toast', { duration, order })
|
|
42
|
+
|
|
43
|
+
function show(message: ReactNode, options: ShowToastOptions) {
|
|
44
|
+
enqueue({
|
|
45
|
+
message,
|
|
46
|
+
type: options.type,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
useImperativeHandle(ref, () => ({ show }))
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<NotificationRegion
|
|
54
|
+
name="toast"
|
|
55
|
+
state={state}
|
|
56
|
+
position={position}
|
|
57
|
+
{...regionProps}
|
|
58
|
+
>
|
|
59
|
+
{state.visibleToasts.map((toast) => (
|
|
60
|
+
<NotificationItem
|
|
61
|
+
key={toast.key}
|
|
62
|
+
name="toast"
|
|
63
|
+
toast={toast}
|
|
64
|
+
state={state}
|
|
65
|
+
itemRef={itemRef}
|
|
66
|
+
onHoverStart={onHoverStart}
|
|
67
|
+
onHoverEnd={onHoverEnd}
|
|
68
|
+
data-type={toast.content.type}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
</NotificationRegion>
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export default Toast
|
|
76
|
+
|
|
77
|
+
export function useToast(props: ToastProps = {}) {
|
|
78
|
+
'use memo'
|
|
79
|
+
|
|
80
|
+
const toastHandlerRef = useRef<ToastHandler>(null)
|
|
81
|
+
const element = <Toast ref={toastHandlerRef} {...props} />
|
|
82
|
+
function show(message: ReactNode, options: ShowToastOptions) {
|
|
83
|
+
toastHandlerRef.current?.show(message, options)
|
|
84
|
+
}
|
|
85
|
+
return [element, show] as const
|
|
86
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.charcoal-notification-region {
|
|
2
|
+
position: fixed;
|
|
3
|
+
left: 0;
|
|
4
|
+
right: 0;
|
|
5
|
+
display: grid;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
pointer-events: none;
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.charcoal-notification {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
border-radius: var(--charcoal-radius-oval);
|
|
14
|
+
width: fit-content;
|
|
15
|
+
max-width: calc(100vw - var(--charcoal-space-46));
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
gap: var(--charcoal-space-20);
|
|
20
|
+
pointer-events: auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.charcoal-notification-content {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.charcoal-notification-label {
|
|
30
|
+
min-width: 0;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
overflow-wrap: break-word;
|
|
33
|
+
display: -webkit-box;
|
|
34
|
+
-webkit-box-orient: vertical;
|
|
35
|
+
-webkit-line-clamp: 2;
|
|
36
|
+
font-size: var(--charcoal-text-font-size-caption-m);
|
|
37
|
+
font-weight: var(--charcoal-text-font-weight-bold);
|
|
38
|
+
line-height: var(--charcoal-text-line-height-caption-m);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (prefers-reduced-motion: reduce) {
|
|
42
|
+
|
|
43
|
+
.charcoal-notification, .charcoal-notification[data-exiting='true'] {
|
|
44
|
+
animation: none;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|