@charcoal-ui/react 6.1.0-beta.0 → 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.
- 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 +45 -0
- package/dist/components/Notification/Snackbar/index.d.ts.map +1 -0
- package/dist/components/Notification/Snackbar/index2.cjs +99 -0
- package/dist/components/Notification/Snackbar/index2.cjs.map +1 -0
- package/dist/components/Notification/Snackbar/index2.js +94 -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 +81 -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/index.cjs +4 -1
- package/dist/index.css +1315 -1250
- 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 +1315 -1250
- 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 +52 -23
- package/src/components/{Snackbar → Notification/Snackbar}/index.test.tsx +61 -11
- package/src/components/Notification/Snackbar/index.tsx +131 -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 +174 -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 +14 -2
- 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,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
|
+
})
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
.charcoal-notification,
|
|
43
|
+
.charcoal-notification[data-exiting='true'] {
|
|
44
|
+
animation: none;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type NotificationName = 'snackbar' | 'toast'
|
|
2
|
+
export type Position = 'top' | 'bottom'
|
|
3
|
+
export type NotificationOrder = 'queue' | 'replace'
|
|
4
|
+
|
|
5
|
+
export type UseNotificationOptions = {
|
|
6
|
+
position?: Position
|
|
7
|
+
/**
|
|
8
|
+
* 画面端からの距離(ピクセル)
|
|
9
|
+
* @default 16
|
|
10
|
+
*/
|
|
11
|
+
offset?: number
|
|
12
|
+
/**
|
|
13
|
+
* 通知を表示する時間(ミリ秒)。負の値は 0、数値でない値は 5000 として扱う
|
|
14
|
+
* @default 5000
|
|
15
|
+
*/
|
|
16
|
+
duration?: number
|
|
17
|
+
/**
|
|
18
|
+
* 表示中の通知がある場合の次の通知の表示方法
|
|
19
|
+
* @default 'queue'
|
|
20
|
+
*/
|
|
21
|
+
order?: NotificationOrder
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NotificationProps = UseNotificationOptions & {
|
|
25
|
+
zIndex?: number
|
|
26
|
+
portalContainer?: HTMLElement
|
|
27
|
+
className?: string
|
|
28
|
+
}
|