@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,69 @@
1
+ .charcoal-snackbar-region {
2
+ --charcoal-snackbar-offset: 16px;
3
+ }
4
+
5
+ .charcoal-snackbar-region[data-position='top'] {
6
+ --charcoal-snackbar-enter-offset: calc(-1 * var(--charcoal-space-30));
7
+ top: calc(
8
+ env(safe-area-inset-top, 0px) + max(var(--charcoal-snackbar-offset), 16px)
9
+ );
10
+ }
11
+
12
+ .charcoal-snackbar-region[data-position='bottom'] {
13
+ --charcoal-snackbar-enter-offset: var(--charcoal-space-30);
14
+ bottom: calc(
15
+ env(safe-area-inset-bottom, 0px) +
16
+ max(var(--charcoal-snackbar-offset), 16px)
17
+ );
18
+ }
19
+
20
+ .charcoal-snackbar {
21
+ min-height: 44px;
22
+ padding: var(--charcoal-space-25) var(--charcoal-space-40);
23
+ background-color: var(--charcoal-color-container-default);
24
+ color: var(--charcoal-color-text-default);
25
+ border: var(--charcoal-border-width-m) solid
26
+ var(--charcoal-color-border-secondary);
27
+ animation: charcoal-snackbar-enter 0.3s ease-out both;
28
+ }
29
+
30
+ .charcoal-snackbar[data-exiting='true'] {
31
+ animation: charcoal-snackbar-exit 0.3s ease-out both;
32
+ }
33
+
34
+ .charcoal-snackbar[data-with-action='true'] {
35
+ min-height: 56px;
36
+ padding: var(--charcoal-space-25) var(--charcoal-space-30)
37
+ var(--charcoal-space-25) var(--charcoal-space-40);
38
+ }
39
+
40
+ .charcoal-snackbar[data-dim='true'] {
41
+ background-color: var(--charcoal-color-container-hud-default);
42
+ color: var(--charcoal-color-text-on-hud-default);
43
+ border-color: var(--charcoal-color-border-hud);
44
+ }
45
+
46
+ @keyframes charcoal-snackbar-enter {
47
+
48
+ from {
49
+ opacity: 0;
50
+ transform: translateY(var(--charcoal-snackbar-enter-offset));
51
+ }
52
+
53
+ to {
54
+ opacity: 1;
55
+ transform: translateY(0);
56
+ }
57
+ }
58
+
59
+ @keyframes charcoal-snackbar-exit {
60
+
61
+ from {
62
+ opacity: 1;
63
+ }
64
+
65
+ to {
66
+ opacity: 0;
67
+ }
68
+ }
69
+
@@ -0,0 +1,68 @@
1
+ .charcoal-snackbar-region {
2
+ --charcoal-snackbar-offset: 16px;
3
+
4
+ &[data-position='top'] {
5
+ --charcoal-snackbar-enter-offset: calc(-1 * var(--charcoal-space-30));
6
+
7
+ top: calc(
8
+ env(safe-area-inset-top, 0px) + max(var(--charcoal-snackbar-offset), 16px)
9
+ );
10
+ }
11
+
12
+ &[data-position='bottom'] {
13
+ --charcoal-snackbar-enter-offset: var(--charcoal-space-30);
14
+
15
+ bottom: calc(
16
+ env(safe-area-inset-bottom, 0px) +
17
+ max(var(--charcoal-snackbar-offset), 16px)
18
+ );
19
+ }
20
+ }
21
+
22
+ .charcoal-snackbar {
23
+ min-height: 44px;
24
+ padding: var(--charcoal-space-25) var(--charcoal-space-40);
25
+ background-color: var(--charcoal-color-container-default);
26
+ color: var(--charcoal-color-text-default);
27
+ border: var(--charcoal-border-width-m) solid
28
+ var(--charcoal-color-border-secondary);
29
+ animation: charcoal-snackbar-enter 0.3s ease-out both;
30
+
31
+ &[data-exiting='true'] {
32
+ animation: charcoal-snackbar-exit 0.3s ease-out both;
33
+ }
34
+
35
+ &[data-with-action='true'] {
36
+ min-height: 56px;
37
+ padding: var(--charcoal-space-25) var(--charcoal-space-30)
38
+ var(--charcoal-space-25) var(--charcoal-space-40);
39
+ }
40
+
41
+ &[data-dim='true'] {
42
+ background-color: var(--charcoal-color-container-hud-default);
43
+ color: var(--charcoal-color-text-on-hud-default);
44
+ border-color: var(--charcoal-color-border-hud);
45
+ }
46
+ }
47
+
48
+ @keyframes charcoal-snackbar-enter {
49
+ from {
50
+ opacity: 0;
51
+ transform: translateY(var(--charcoal-snackbar-enter-offset));
52
+ }
53
+
54
+ to {
55
+ opacity: 1;
56
+ transform: translateY(0);
57
+ }
58
+ }
59
+
60
+ @keyframes charcoal-snackbar-exit {
61
+ from {
62
+ opacity: 1;
63
+ }
64
+
65
+ to {
66
+ opacity: 0;
67
+ }
68
+ }
@@ -0,0 +1,213 @@
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
+ actionChildren?: string
14
+ }
15
+
16
+ export default {
17
+ title: 'react/unstable_Snackbar',
18
+ component: unstable_Snackbar,
19
+ parameters: {
20
+ layout: 'centered',
21
+ tokenVersion: 'v2',
22
+ controls: {
23
+ sort: 'requiredFirst',
24
+ },
25
+ docs: {
26
+ description: {
27
+ component: `同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合は、\`order\` に応じてキューに積むか、表示中のスナックバーを置き換えます。
28
+
29
+ 別々の \`useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。`,
30
+ },
31
+ },
32
+ },
33
+ args: {
34
+ message: '保存しました',
35
+ order: 'queue',
36
+ },
37
+ argTypes: {
38
+ position: {
39
+ options: ['top', 'bottom'],
40
+ control: { type: 'inline-radio' },
41
+ table: { category: 'Hook' },
42
+ },
43
+ offset: {
44
+ control: { type: 'number', min: 0, step: 1 },
45
+ table: { category: 'Hook' },
46
+ },
47
+ duration: {
48
+ control: { type: 'number', min: 0, step: 500 },
49
+ description: '表示時間(ミリ秒)',
50
+ table: { category: 'Hook' },
51
+ },
52
+ order: {
53
+ options: ['queue', 'replace'],
54
+ control: { type: 'inline-radio' },
55
+ table: { category: 'Hook' },
56
+ },
57
+ dim: {
58
+ control: 'boolean',
59
+ table: { category: 'Hook' },
60
+ },
61
+ zIndex: {
62
+ control: { type: 'number', min: 0, step: 1 },
63
+ table: { category: 'Hook' },
64
+ },
65
+ className: {
66
+ control: 'text',
67
+ table: { category: 'Hook' },
68
+ },
69
+ portalContainer: {
70
+ control: false,
71
+ table: { category: 'Hook' },
72
+ },
73
+ css: {
74
+ table: { disable: true },
75
+ },
76
+ message: {
77
+ control: 'text',
78
+ table: { category: 'Show' },
79
+ },
80
+ actionChildren: {
81
+ name: 'action',
82
+ control: 'text',
83
+ table: { category: 'Show' },
84
+ },
85
+ },
86
+ render: (args) => <SnackbarDemo {...args} />,
87
+ } as Meta<SnackbarStoryArgs>
88
+
89
+ function SnackbarDemo({
90
+ message,
91
+ actionChildren,
92
+ ...props
93
+ }: SnackbarStoryArgs) {
94
+ const [snackbar, showSnackbar] = unstable_useSnackbar(props)
95
+ const hasAction = actionChildren !== undefined && actionChildren !== ''
96
+ const showOptions = {
97
+ ...(hasAction ? { action: <Button>{actionChildren}</Button> } : {}),
98
+ }
99
+
100
+ useEffect(() => {
101
+ if (!defaultOpen) {
102
+ return
103
+ }
104
+ showSnackbar(message, {
105
+ ...(hasAction ? { action: <Button>{actionChildren}</Button> } : {}),
106
+ })
107
+ }, [actionChildren, hasAction, message, showSnackbar])
108
+
109
+ return (
110
+ <>
111
+ {snackbar}
112
+ <Button
113
+ onClick={() => {
114
+ showSnackbar(message, showOptions)
115
+ }}
116
+ >
117
+ show
118
+ </Button>
119
+ </>
120
+ )
121
+ }
122
+
123
+ export const Default: StoryObj<SnackbarStoryArgs> = {
124
+ parameters: {
125
+ docs: {
126
+ source: {
127
+ language: 'tsx',
128
+ code: `import { Button, unstable_useSnackbar } from '@charcoal-ui/react'
129
+
130
+ export function Example() {
131
+ const [snackbar, showSnackbar] = unstable_useSnackbar()
132
+
133
+ return (
134
+ <>
135
+ {snackbar}
136
+ <Button onClick={() => showSnackbar('保存しました')}>
137
+ 保存
138
+ </Button>
139
+ </>
140
+ )
141
+ }`,
142
+ },
143
+ },
144
+ },
145
+ }
146
+
147
+ export const LongMessage: StoryObj<SnackbarStoryArgs> = {
148
+ args: {
149
+ message:
150
+ '保存した内容はすべての端末に同期され、あとから設定画面で変更できます。長いメッセージは2行を超えると省略されます',
151
+ },
152
+ }
153
+
154
+ export const WithLineBreak: StoryObj<SnackbarStoryArgs> = {
155
+ args: {
156
+ message: (
157
+ <>
158
+ 保存に失敗しました
159
+ <br />
160
+ 通信環境を確認してください
161
+ </>
162
+ ),
163
+ },
164
+ }
165
+
166
+ export const WithAction: StoryObj<SnackbarStoryArgs> = {
167
+ args: {
168
+ message: '保存しました',
169
+ actionChildren: '取り消す',
170
+ },
171
+ parameters: {
172
+ docs: {
173
+ source: {
174
+ language: 'tsx',
175
+ code: `import { Button, unstable_useSnackbar } from '@charcoal-ui/react'
176
+
177
+ export function Example() {
178
+ const [snackbar, showSnackbar] = unstable_useSnackbar()
179
+
180
+ return (
181
+ <>
182
+ {snackbar}
183
+ <Button
184
+ onClick={() =>
185
+ showSnackbar('保存しました', {
186
+ action: <Button>取り消す</Button>,
187
+ })
188
+ }
189
+ >
190
+ 保存
191
+ </Button>
192
+ </>
193
+ )
194
+ }`,
195
+ },
196
+ },
197
+ },
198
+ }
199
+
200
+ export const Top: StoryObj<SnackbarStoryArgs> = {
201
+ args: {
202
+ position: 'top',
203
+ message: '上部に表示します',
204
+ },
205
+ }
206
+
207
+ export const Dim: StoryObj<SnackbarStoryArgs> = {
208
+ args: {
209
+ dim: true,
210
+ message: 'Dim の Snackbar',
211
+ actionChildren: '閉じる',
212
+ },
213
+ }
@@ -0,0 +1,344 @@
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({ duration })
66
+
67
+ show('保存しました')
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({ duration })
88
+
89
+ show('保存しました')
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('replaces the current snackbar immediately when requested', () => {
124
+ const onClose = vi.fn()
125
+ const { show } = renderSnackbar({ order: 'replace' })
126
+
127
+ show('first', { onClose })
128
+ show('second')
129
+
130
+ expect(screen.queryByText('first')).not.toBeInTheDocument()
131
+ expect(screen.getByText('second')).toBeInTheDocument()
132
+ expect(onClose).toHaveBeenCalledExactlyOnceWith('replaced')
133
+ })
134
+
135
+ it('reports timeout when the snackbar closes after its duration', () => {
136
+ const onClose = vi.fn()
137
+ const { show } = renderSnackbar({ duration: 0 })
138
+
139
+ show('保存しました', { onClose })
140
+ act(() => {
141
+ vi.advanceTimersByTime(1)
142
+ })
143
+
144
+ expect(onClose).toHaveBeenCalledExactlyOnceWith('timeout')
145
+ })
146
+
147
+ it('reports action when the action is clicked', () => {
148
+ const onClose = vi.fn()
149
+ const { show } = renderSnackbar()
150
+
151
+ show('保存しました', {
152
+ action: <button type="button">取り消す</button>,
153
+ onClose,
154
+ })
155
+ fireEvent.click(screen.getByRole('button', { name: '取り消す' }))
156
+
157
+ expect(onClose).toHaveBeenCalledExactlyOnceWith('action')
158
+ })
159
+
160
+ it('reports unmounted only for the currently displayed snackbar', () => {
161
+ const firstOnClose = vi.fn()
162
+ const secondOnClose = vi.fn()
163
+ const { show, unmount } = renderSnackbar()
164
+
165
+ show('first', { onClose: firstOnClose })
166
+ show('second', { onClose: secondOnClose })
167
+ unmount()
168
+
169
+ expect(firstOnClose).toHaveBeenCalledExactlyOnceWith('unmounted')
170
+ expect(secondOnClose).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('keeps the snackbar open while hovered after the timer ends, then closes on leave', () => {
174
+ const { show } = renderSnackbar()
175
+
176
+ show('保存しました')
177
+ const snackbar = screen
178
+ .getByText('保存しました')
179
+ .closest('.charcoal-snackbar')
180
+ if (snackbar === null) throw new Error('Snackbar not found')
181
+
182
+ fireEvent.pointerEnter(snackbar, { pointerType: 'mouse' })
183
+ act(() => {
184
+ vi.advanceTimersByTime(5000)
185
+ })
186
+ expect(snackbar).not.toHaveAttribute('data-exiting', 'true')
187
+
188
+ fireEvent.pointerLeave(snackbar)
189
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
190
+ })
191
+
192
+ it('keeps the snackbar open while focused', () => {
193
+ const { show } = renderSnackbar()
194
+
195
+ show('保存しました')
196
+ const snackbar = screen
197
+ .getByText('保存しました')
198
+ .closest('.charcoal-snackbar')
199
+ if (snackbar === null) throw new Error('Snackbar not found')
200
+
201
+ fireEvent.keyDown(document, { key: 'F6' })
202
+ expect(screen.getByRole('region')).toHaveFocus()
203
+
204
+ act(() => {
205
+ vi.advanceTimersByTime(5000)
206
+ })
207
+ expect(snackbar).not.toHaveAttribute('data-exiting', 'true')
208
+
209
+ fireEvent.blur(screen.getByRole('region'))
210
+ act(() => {
211
+ vi.advanceTimersByTime(5000)
212
+ })
213
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
214
+ })
215
+
216
+ it('places a snackbar with an action at the bottom', () => {
217
+ const { show } = renderSnackbar({ position: 'top' })
218
+
219
+ show('保存しました', {
220
+ action: <button type="button">取り消す</button>,
221
+ })
222
+
223
+ expect(screen.getByRole('region')).toHaveAttribute(
224
+ 'data-position',
225
+ 'bottom',
226
+ )
227
+ })
228
+
229
+ it('places a snackbar without an action at the top when requested', () => {
230
+ const { show } = renderSnackbar({ position: 'top' })
231
+
232
+ show('保存しました')
233
+
234
+ expect(screen.getByRole('region')).toHaveAttribute('data-position', 'top')
235
+ })
236
+
237
+ it('supports a custom z-index and portal container', () => {
238
+ const portalContainer = document.createElement('div')
239
+ document.body.append(portalContainer)
240
+ const { show } = renderSnackbar({ zIndex: 30, portalContainer })
241
+
242
+ show('保存しました')
243
+
244
+ expect(portalContainer).toContainElement(screen.getByRole('region'))
245
+ expect(screen.getByRole('region')).toHaveStyle({ zIndex: 30 })
246
+
247
+ portalContainer.remove()
248
+ })
249
+
250
+ it('moves focus to the toast region with F6', () => {
251
+ const { show } = renderSnackbar()
252
+
253
+ show('保存しました', {
254
+ action: <button type="button">取り消す</button>,
255
+ })
256
+
257
+ fireEvent.keyDown(document, { key: 'F6' })
258
+
259
+ expect(screen.getByRole('region')).toHaveFocus()
260
+ })
261
+
262
+ it('restores focus after a focused snackbar closes', () => {
263
+ const { show } = renderSnackbar()
264
+ const trigger = document.createElement('button')
265
+ document.body.append(trigger)
266
+ trigger.focus()
267
+
268
+ show('保存しました', {
269
+ action: <button type="button">閉じる</button>,
270
+ })
271
+
272
+ fireEvent.keyDown(document, { key: 'F6' })
273
+ expect(screen.getByRole('region')).toHaveFocus()
274
+
275
+ fireEvent.click(screen.getByRole('button', { name: '閉じる' }))
276
+ finishExitAnimation('保存しました')
277
+
278
+ expect(trigger).toHaveFocus()
279
+ trigger.remove()
280
+ })
281
+
282
+ it('closes on action click even while hovered', () => {
283
+ const { show } = renderSnackbar()
284
+ const trigger = document.createElement('button')
285
+ document.body.append(trigger)
286
+ trigger.focus()
287
+
288
+ show('保存しました', {
289
+ action: <button type="button">閉じる</button>,
290
+ })
291
+
292
+ const snackbar = screen
293
+ .getByText('保存しました')
294
+ .closest('.charcoal-snackbar')
295
+ if (snackbar === null) throw new Error('Snackbar not found')
296
+
297
+ fireEvent.pointerEnter(snackbar, { pointerType: 'mouse' })
298
+ fireEvent.click(screen.getByRole('button', { name: '閉じる' }))
299
+
300
+ expect(snackbar).toHaveAttribute('data-exiting', 'true')
301
+
302
+ finishExitAnimation('保存しました')
303
+ expect(screen.queryByText('保存しました')).not.toBeInTheDocument()
304
+ expect(trigger).toHaveFocus()
305
+ trigger.remove()
306
+ })
307
+ })
308
+
309
+ describe('useSnackbar', () => {
310
+ beforeEach(() => {
311
+ vi.useFakeTimers()
312
+ })
313
+
314
+ afterEach(() => {
315
+ cleanup()
316
+ vi.useRealTimers()
317
+ })
318
+
319
+ it('shows a snackbar via the returned void function', () => {
320
+ let showResult: void | undefined
321
+
322
+ function HookApp() {
323
+ const [snackbar, show] = useSnackbar()
324
+ return (
325
+ <>
326
+ {snackbar}
327
+ <button
328
+ type="button"
329
+ onClick={() => {
330
+ showResult = show('hook message')
331
+ }}
332
+ >
333
+ open
334
+ </button>
335
+ </>
336
+ )
337
+ }
338
+
339
+ render(<HookApp />)
340
+ fireEvent.click(screen.getByText('open'))
341
+ expect(screen.getByText('hook message')).toBeInTheDocument()
342
+ expect(showResult).toBeUndefined()
343
+ })
344
+ })