@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.
Files changed (67) 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 +29 -0
  14. package/dist/components/Notification/Snackbar/index.d.ts.map +1 -0
  15. package/dist/components/Notification/Snackbar/index2.cjs +79 -0
  16. package/dist/components/Notification/Snackbar/index2.cjs.map +1 -0
  17. package/dist/components/Notification/Snackbar/index2.js +78 -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 +85 -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 +60 -52
  34. package/dist/components/Pagination/index2.js.map +1 -1
  35. package/dist/index.cjs +5 -2
  36. package/dist/index.css +822 -757
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -2
  40. package/dist/layered.css +822 -757
  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/{Snackbar → Notification/Snackbar}/__snapshots__/index.css.snap +1 -42
  47. package/src/components/{Snackbar → Notification/Snackbar}/index.css +1 -43
  48. package/src/components/{Snackbar → Notification/Snackbar}/index.story.tsx +70 -34
  49. package/src/components/{Snackbar → Notification/Snackbar}/index.test.tsx +99 -18
  50. package/src/components/Notification/Snackbar/index.tsx +136 -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 +177 -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 +17 -5
  61. package/dist/components/Snackbar/index.d.ts +0 -52
  62. package/dist/components/Snackbar/index.d.ts.map +0 -1
  63. package/dist/components/Snackbar/index2.cjs +0 -268
  64. package/dist/components/Snackbar/index2.cjs.map +0 -1
  65. package/dist/components/Snackbar/index2.js +0 -263
  66. package/dist/components/Snackbar/index2.js.map +0 -1
  67. 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
+