@charcoal-ui/react 6.0.1 → 6.1.0-beta.0

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.
@@ -0,0 +1,184 @@
1
+ import { useEffect, type ReactNode } from 'react'
2
+ import { Meta, StoryObj } from '@storybook/react-vite'
3
+ import Button from '../Button'
4
+ import unstable_Snackbar, {
5
+ useSnackbar as unstable_useSnackbar,
6
+ type SnackbarProps as unstable_SnackbarProps,
7
+ } from '.'
8
+
9
+ const defaultOpen = !!process.env.TEST
10
+
11
+ type SnackbarStoryArgs = unstable_SnackbarProps & {
12
+ message: ReactNode
13
+ buttonChildren?: string
14
+ duration?: number
15
+ }
16
+
17
+ export default {
18
+ title: 'react/unstable_Snackbar',
19
+ component: unstable_Snackbar,
20
+ parameters: {
21
+ layout: 'centered',
22
+ tokenVersion: 'v2',
23
+ docs: {
24
+ description: {
25
+ component: `同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合はキューに積み、前のスナックバーが消えてから表示します。
26
+
27
+ 別々の \`useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。`,
28
+ },
29
+ },
30
+ },
31
+ args: {
32
+ message: '保存しました',
33
+ duration: 5000,
34
+ },
35
+ argTypes: {
36
+ position: {
37
+ options: ['top', 'bottom'],
38
+ control: { type: 'inline-radio' },
39
+ },
40
+ message: {
41
+ control: 'text',
42
+ },
43
+ duration: {
44
+ control: { type: 'number', min: 0, step: 500 },
45
+ description: '表示時間(ミリ秒)',
46
+ },
47
+ buttonChildren: {
48
+ name: 'button.children',
49
+ control: 'text',
50
+ },
51
+ },
52
+ render: (args) => <SnackbarDemo {...args} />,
53
+ } as Meta<SnackbarStoryArgs>
54
+
55
+ function SnackbarDemo({
56
+ message,
57
+ buttonChildren,
58
+ duration,
59
+ ...props
60
+ }: SnackbarStoryArgs) {
61
+ const [snackbar, showSnackbar] = unstable_useSnackbar(props)
62
+ const hasButton = buttonChildren !== undefined && buttonChildren !== ''
63
+ const showOptions = {
64
+ duration,
65
+ ...(hasButton ? { button: { children: buttonChildren } } : {}),
66
+ }
67
+
68
+ useEffect(() => {
69
+ if (!defaultOpen) {
70
+ return
71
+ }
72
+ showSnackbar(message, {
73
+ duration: 60_000,
74
+ ...(hasButton ? { button: { children: buttonChildren } } : {}),
75
+ })
76
+ }, [buttonChildren, hasButton, message, showSnackbar])
77
+
78
+ return (
79
+ <>
80
+ {snackbar}
81
+ <Button
82
+ onClick={() => {
83
+ showSnackbar(message, showOptions)
84
+ }}
85
+ >
86
+ show
87
+ </Button>
88
+ </>
89
+ )
90
+ }
91
+
92
+ export const Default: StoryObj<SnackbarStoryArgs> = {
93
+ parameters: {
94
+ docs: {
95
+ source: {
96
+ language: 'tsx',
97
+ code: `import { Button, unstable_useSnackbar } from '@charcoal-ui/react'
98
+
99
+ export function Example() {
100
+ const [snackbar, showSnackbar] = unstable_useSnackbar()
101
+
102
+ return (
103
+ <>
104
+ {snackbar}
105
+ <Button onClick={() => showSnackbar('保存しました')}>
106
+ 保存
107
+ </Button>
108
+ </>
109
+ )
110
+ }`,
111
+ },
112
+ },
113
+ },
114
+ }
115
+
116
+ export const LongMessage: StoryObj<SnackbarStoryArgs> = {
117
+ args: {
118
+ message:
119
+ '保存した内容はすべての端末に同期され、あとから設定画面で変更できます。長いメッセージは2行を超えると省略されます',
120
+ },
121
+ }
122
+
123
+ export const WithLineBreak: StoryObj<SnackbarStoryArgs> = {
124
+ args: {
125
+ message: (
126
+ <>
127
+ 保存に失敗しました
128
+ <br />
129
+ 通信環境を確認してください
130
+ </>
131
+ ),
132
+ },
133
+ }
134
+
135
+ export const WithButton: StoryObj<SnackbarStoryArgs> = {
136
+ args: {
137
+ message: '保存しました',
138
+ buttonChildren: '取り消す',
139
+ },
140
+ parameters: {
141
+ docs: {
142
+ source: {
143
+ language: 'tsx',
144
+ code: `import { Button, unstable_useSnackbar } from '@charcoal-ui/react'
145
+
146
+ export function Example() {
147
+ const [snackbar, showSnackbar] = unstable_useSnackbar()
148
+
149
+ return (
150
+ <>
151
+ {snackbar}
152
+ <Button
153
+ onClick={() =>
154
+ showSnackbar('保存しました', {
155
+ button: {
156
+ children: '取り消す',
157
+ },
158
+ })
159
+ }
160
+ >
161
+ 保存
162
+ </Button>
163
+ </>
164
+ )
165
+ }`,
166
+ },
167
+ },
168
+ },
169
+ }
170
+
171
+ export const Top: StoryObj<SnackbarStoryArgs> = {
172
+ args: {
173
+ position: 'top',
174
+ message: '上部に表示します',
175
+ },
176
+ }
177
+
178
+ export const Dim: StoryObj<SnackbarStoryArgs> = {
179
+ args: {
180
+ dim: true,
181
+ message: 'Dim の Snackbar',
182
+ buttonChildren: '閉じる',
183
+ },
184
+ }
@@ -0,0 +1,294 @@
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 Snackbar, { useSnackbar, type SnackbarHandler } from '.'
5
+
6
+ function renderSnackbar(props: ComponentProps<typeof Snackbar> = {}) {
7
+ const ref = createRef<SnackbarHandler>()
8
+ const result = render(<Snackbar ref={ref} {...props} />)
9
+ const show: SnackbarHandler['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 snackbar = screen.getByText(message).closest('.charcoal-snackbar')
22
+ if (snackbar === null) throw new Error('Snackbar not found')
23
+ fireEvent(
24
+ snackbar,
25
+ Object.assign(new Event('animationend', { bubbles: true }), {
26
+ animationName: 'charcoal-snackbar-exit',
27
+ }),
28
+ )
29
+ }
30
+
31
+ describe('Snackbar', () => {
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 } = renderSnackbar()
43
+
44
+ expect(screen.queryByRole('region')).not.toBeInTheDocument()
45
+
46
+ show('保存しました')
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-snackbar'),
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 } = renderSnackbar()
66
+
67
+ show('保存しました', { duration })
68
+ expect(screen.getByText('保存しました')).toBeInTheDocument()
69
+
70
+ act(() => {
71
+ vi.advanceTimersByTime(1)
72
+ })
73
+ expect(
74
+ screen.getByText('保存しました').closest('.charcoal-snackbar'),
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 } = renderSnackbar()
88
+
89
+ show('保存しました', { duration })
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-snackbar'),
100
+ ).toHaveAttribute('data-exiting', 'true')
101
+ })
102
+
103
+ it('queues the next snackbar until the previous one closes', async () => {
104
+ const { show } = renderSnackbar()
105
+
106
+ show('first')
107
+ show('second')
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
+ })
122
+
123
+ it('keeps the snackbar open while hovered after the timer ends, then closes on leave', () => {
124
+ const { show } = renderSnackbar()
125
+
126
+ show('保存しました')
127
+ const snackbar = screen
128
+ .getByText('保存しました')
129
+ .closest('.charcoal-snackbar')
130
+ if (snackbar === null) throw new Error('Snackbar not found')
131
+
132
+ fireEvent.pointerEnter(snackbar, { pointerType: 'mouse' })
133
+ act(() => {
134
+ vi.advanceTimersByTime(5000)
135
+ })
136
+ expect(snackbar).not.toHaveAttribute('data-exiting', 'true')
137
+
138
+ fireEvent.pointerLeave(snackbar)
139
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
140
+ })
141
+
142
+ it('keeps the snackbar open while focused', () => {
143
+ const { show } = renderSnackbar()
144
+
145
+ show('保存しました')
146
+ const snackbar = screen
147
+ .getByText('保存しました')
148
+ .closest('.charcoal-snackbar')
149
+ if (snackbar === null) throw new Error('Snackbar not found')
150
+
151
+ fireEvent.keyDown(document, { key: 'F6' })
152
+ expect(screen.getByRole('region')).toHaveFocus()
153
+
154
+ act(() => {
155
+ vi.advanceTimersByTime(5000)
156
+ })
157
+ expect(snackbar).not.toHaveAttribute('data-exiting', 'true')
158
+
159
+ fireEvent.blur(screen.getByRole('region'))
160
+ act(() => {
161
+ vi.advanceTimersByTime(5000)
162
+ })
163
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
164
+ })
165
+
166
+ it('places a snackbar with a button at the bottom', () => {
167
+ const { show } = renderSnackbar({ position: 'top' })
168
+
169
+ show('保存しました', {
170
+ button: { children: '取り消す' },
171
+ })
172
+
173
+ expect(screen.getByRole('region')).toHaveAttribute(
174
+ 'data-position',
175
+ 'bottom',
176
+ )
177
+ })
178
+
179
+ it('places a snackbar without a button at the top when requested', () => {
180
+ const { show } = renderSnackbar({ position: 'top' })
181
+
182
+ show('保存しました')
183
+
184
+ expect(screen.getByRole('region')).toHaveAttribute('data-position', 'top')
185
+ })
186
+
187
+ it('supports a custom z-index and portal container', () => {
188
+ const portalContainer = document.createElement('div')
189
+ document.body.append(portalContainer)
190
+ const { show } = renderSnackbar({ zIndex: 30, portalContainer })
191
+
192
+ show('保存しました')
193
+
194
+ expect(portalContainer).toContainElement(screen.getByRole('region'))
195
+ expect(screen.getByRole('region')).toHaveStyle({ zIndex: 30 })
196
+
197
+ portalContainer.remove()
198
+ })
199
+
200
+ it('moves focus to the toast region with F6', () => {
201
+ const { show } = renderSnackbar()
202
+
203
+ show('保存しました', {
204
+ button: { children: '取り消す' },
205
+ })
206
+
207
+ fireEvent.keyDown(document, { key: 'F6' })
208
+
209
+ expect(screen.getByRole('region')).toHaveFocus()
210
+ })
211
+
212
+ it('restores focus after a focused snackbar closes', () => {
213
+ const { show } = renderSnackbar()
214
+ const trigger = document.createElement('button')
215
+ document.body.append(trigger)
216
+ trigger.focus()
217
+
218
+ show('保存しました', {
219
+ button: { children: '閉じる' },
220
+ })
221
+
222
+ fireEvent.keyDown(document, { key: 'F6' })
223
+ expect(screen.getByRole('region')).toHaveFocus()
224
+
225
+ fireEvent.click(screen.getByRole('button', { name: '閉じる' }))
226
+ finishExitAnimation('保存しました')
227
+
228
+ expect(trigger).toHaveFocus()
229
+ trigger.remove()
230
+ })
231
+
232
+ it('closes on button click even while hovered', () => {
233
+ const { show } = renderSnackbar()
234
+ const trigger = document.createElement('button')
235
+ document.body.append(trigger)
236
+ trigger.focus()
237
+
238
+ show('保存しました', {
239
+ button: { children: '閉じる' },
240
+ })
241
+
242
+ const snackbar = screen
243
+ .getByText('保存しました')
244
+ .closest('.charcoal-snackbar')
245
+ if (snackbar === null) throw new Error('Snackbar not found')
246
+
247
+ fireEvent.pointerEnter(snackbar, { pointerType: 'mouse' })
248
+ fireEvent.click(screen.getByRole('button', { name: '閉じる' }))
249
+
250
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
251
+
252
+ finishExitAnimation('保存しました')
253
+ expect(screen.queryByText('保存しました')).not.toBeInTheDocument()
254
+ expect(trigger).toHaveFocus()
255
+ trigger.remove()
256
+ })
257
+ })
258
+
259
+ describe('useSnackbar', () => {
260
+ beforeEach(() => {
261
+ vi.useFakeTimers()
262
+ })
263
+
264
+ afterEach(() => {
265
+ cleanup()
266
+ vi.useRealTimers()
267
+ })
268
+
269
+ it('shows a snackbar via the returned void function', () => {
270
+ let showResult: void | undefined
271
+
272
+ function HookApp() {
273
+ const [snackbar, show] = useSnackbar()
274
+ return (
275
+ <>
276
+ {snackbar}
277
+ <button
278
+ type="button"
279
+ onClick={() => {
280
+ showResult = show('hook message')
281
+ }}
282
+ >
283
+ open
284
+ </button>
285
+ </>
286
+ )
287
+ }
288
+
289
+ render(<HookApp />)
290
+ fireEvent.click(screen.getByText('open'))
291
+ expect(screen.getByText('hook message')).toBeInTheDocument()
292
+ expect(showResult).toBeUndefined()
293
+ })
294
+ })