@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
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
.charcoal-snackbar-region {
|
|
2
2
|
--charcoal-snackbar-offset: 16px;
|
|
3
|
-
position: fixed;
|
|
4
|
-
left: 0;
|
|
5
|
-
right: 0;
|
|
6
|
-
display: grid;
|
|
7
|
-
justify-content: center;
|
|
8
|
-
pointer-events: none;
|
|
9
|
-
box-sizing: border-box;
|
|
10
3
|
}
|
|
11
4
|
|
|
12
5
|
.charcoal-snackbar-region[data-position='top'] {
|
|
@@ -25,17 +18,8 @@
|
|
|
25
18
|
}
|
|
26
19
|
|
|
27
20
|
.charcoal-snackbar {
|
|
28
|
-
box-sizing: border-box;
|
|
29
|
-
border-radius: var(--charcoal-radius-oval);
|
|
30
|
-
width: fit-content;
|
|
31
|
-
max-width: calc(100vw - var(--charcoal-space-46));
|
|
32
21
|
min-height: 44px;
|
|
33
22
|
padding: var(--charcoal-space-25) var(--charcoal-space-40);
|
|
34
|
-
display: flex;
|
|
35
|
-
align-items: center;
|
|
36
|
-
justify-content: center;
|
|
37
|
-
gap: var(--charcoal-space-20);
|
|
38
|
-
pointer-events: auto;
|
|
39
23
|
background-color: var(--charcoal-color-container-default);
|
|
40
24
|
color: var(--charcoal-color-text-default);
|
|
41
25
|
border: var(--charcoal-border-width-m) solid
|
|
@@ -47,7 +31,7 @@
|
|
|
47
31
|
animation: charcoal-snackbar-exit 0.3s ease-out both;
|
|
48
32
|
}
|
|
49
33
|
|
|
50
|
-
.charcoal-snackbar[data-with-
|
|
34
|
+
.charcoal-snackbar[data-with-action='true'] {
|
|
51
35
|
min-height: 56px;
|
|
52
36
|
padding: var(--charcoal-space-25) var(--charcoal-space-30)
|
|
53
37
|
var(--charcoal-space-25) var(--charcoal-space-40);
|
|
@@ -59,24 +43,6 @@
|
|
|
59
43
|
border-color: var(--charcoal-color-border-hud);
|
|
60
44
|
}
|
|
61
45
|
|
|
62
|
-
.charcoal-snackbar-content {
|
|
63
|
-
display: flex;
|
|
64
|
-
align-items: center;
|
|
65
|
-
min-width: 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
.charcoal-snackbar-label {
|
|
69
|
-
min-width: 0;
|
|
70
|
-
overflow: hidden;
|
|
71
|
-
overflow-wrap: break-word;
|
|
72
|
-
display: -webkit-box;
|
|
73
|
-
-webkit-box-orient: vertical;
|
|
74
|
-
-webkit-line-clamp: 2;
|
|
75
|
-
font-size: var(--charcoal-text-font-size-caption-m);
|
|
76
|
-
font-weight: var(--charcoal-text-font-weight-bold);
|
|
77
|
-
line-height: var(--charcoal-text-line-height-caption-m);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
46
|
@keyframes charcoal-snackbar-enter {
|
|
81
47
|
|
|
82
48
|
from {
|
|
@@ -101,10 +67,3 @@ to {
|
|
|
101
67
|
}
|
|
102
68
|
}
|
|
103
69
|
|
|
104
|
-
@media (prefers-reduced-motion: reduce) {
|
|
105
|
-
|
|
106
|
-
.charcoal-snackbar, .charcoal-snackbar[data-exiting='true'] {
|
|
107
|
-
animation: none;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
.charcoal-snackbar-region {
|
|
2
2
|
--charcoal-snackbar-offset: 16px;
|
|
3
3
|
|
|
4
|
-
position: fixed;
|
|
5
|
-
left: 0;
|
|
6
|
-
right: 0;
|
|
7
|
-
display: grid;
|
|
8
|
-
justify-content: center;
|
|
9
|
-
pointer-events: none;
|
|
10
|
-
box-sizing: border-box;
|
|
11
|
-
|
|
12
4
|
&[data-position='top'] {
|
|
13
5
|
--charcoal-snackbar-enter-offset: calc(-1 * var(--charcoal-space-30));
|
|
14
6
|
|
|
@@ -28,17 +20,8 @@
|
|
|
28
20
|
}
|
|
29
21
|
|
|
30
22
|
.charcoal-snackbar {
|
|
31
|
-
box-sizing: border-box;
|
|
32
|
-
border-radius: var(--charcoal-radius-oval);
|
|
33
|
-
width: fit-content;
|
|
34
|
-
max-width: calc(100vw - var(--charcoal-space-46));
|
|
35
23
|
min-height: 44px;
|
|
36
24
|
padding: var(--charcoal-space-25) var(--charcoal-space-40);
|
|
37
|
-
display: flex;
|
|
38
|
-
align-items: center;
|
|
39
|
-
justify-content: center;
|
|
40
|
-
gap: var(--charcoal-space-20);
|
|
41
|
-
pointer-events: auto;
|
|
42
25
|
background-color: var(--charcoal-color-container-default);
|
|
43
26
|
color: var(--charcoal-color-text-default);
|
|
44
27
|
border: var(--charcoal-border-width-m) solid
|
|
@@ -49,7 +32,7 @@
|
|
|
49
32
|
animation: charcoal-snackbar-exit 0.3s ease-out both;
|
|
50
33
|
}
|
|
51
34
|
|
|
52
|
-
&[data-with-
|
|
35
|
+
&[data-with-action='true'] {
|
|
53
36
|
min-height: 56px;
|
|
54
37
|
padding: var(--charcoal-space-25) var(--charcoal-space-30)
|
|
55
38
|
var(--charcoal-space-25) var(--charcoal-space-40);
|
|
@@ -62,24 +45,6 @@
|
|
|
62
45
|
}
|
|
63
46
|
}
|
|
64
47
|
|
|
65
|
-
.charcoal-snackbar-content {
|
|
66
|
-
display: flex;
|
|
67
|
-
align-items: center;
|
|
68
|
-
min-width: 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.charcoal-snackbar-label {
|
|
72
|
-
min-width: 0;
|
|
73
|
-
overflow: hidden;
|
|
74
|
-
overflow-wrap: break-word;
|
|
75
|
-
display: -webkit-box;
|
|
76
|
-
-webkit-box-orient: vertical;
|
|
77
|
-
-webkit-line-clamp: 2;
|
|
78
|
-
font-size: var(--charcoal-text-font-size-caption-m);
|
|
79
|
-
font-weight: var(--charcoal-text-font-weight-bold);
|
|
80
|
-
line-height: var(--charcoal-text-line-height-caption-m);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
48
|
@keyframes charcoal-snackbar-enter {
|
|
84
49
|
from {
|
|
85
50
|
opacity: 0;
|
|
@@ -101,10 +66,3 @@
|
|
|
101
66
|
opacity: 0;
|
|
102
67
|
}
|
|
103
68
|
}
|
|
104
|
-
|
|
105
|
-
@media (prefers-reduced-motion: reduce) {
|
|
106
|
-
.charcoal-snackbar,
|
|
107
|
-
.charcoal-snackbar[data-exiting='true'] {
|
|
108
|
-
animation: none;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -1,52 +1,92 @@
|
|
|
1
1
|
import { useEffect, type ReactNode } from 'react'
|
|
2
2
|
import { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
-
import Button from '
|
|
4
|
-
import
|
|
5
|
-
useSnackbar as unstable_useSnackbar,
|
|
6
|
-
type SnackbarProps as unstable_SnackbarProps,
|
|
7
|
-
} from '.'
|
|
3
|
+
import Button from '../../Button'
|
|
4
|
+
import { useSnackbar as unstable_useSnackbar } from '.'
|
|
8
5
|
|
|
9
6
|
const defaultOpen = !!process.env.TEST
|
|
10
7
|
|
|
11
|
-
type SnackbarStoryArgs =
|
|
8
|
+
type SnackbarStoryArgs = NonNullable<
|
|
9
|
+
Parameters<typeof unstable_useSnackbar>[0]
|
|
10
|
+
> & {
|
|
12
11
|
message: ReactNode
|
|
13
|
-
|
|
14
|
-
duration?: number
|
|
12
|
+
actionChildren?: string
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export default {
|
|
18
|
-
title: 'react/
|
|
19
|
-
component:
|
|
16
|
+
title: 'react/Snackbar',
|
|
17
|
+
component: SnackbarDemo,
|
|
20
18
|
parameters: {
|
|
21
19
|
layout: 'centered',
|
|
22
20
|
tokenVersion: 'v2',
|
|
21
|
+
controls: {
|
|
22
|
+
sort: 'requiredFirst',
|
|
23
|
+
},
|
|
23
24
|
docs: {
|
|
24
25
|
description: {
|
|
25
|
-
component:
|
|
26
|
+
component: `\`unstable_useSnackbar\` を使用すると、表示時間やキューを含む表示制御を利用できます。同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合は、\`order\` に応じてキューに積むか、表示中のスナックバーを置き換えます。
|
|
27
|
+
|
|
28
|
+
別々の \`unstable_useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
\`UnstableSnackbar\` は表示専用のコンポーネントです。表示状態や表示時間を制御する機構を持たず、アクションの指定を必須とします。表示の切り替えは利用側で制御してください。`,
|
|
28
31
|
},
|
|
29
32
|
},
|
|
30
33
|
},
|
|
31
34
|
args: {
|
|
32
35
|
message: '保存しました',
|
|
36
|
+
position: 'bottom',
|
|
37
|
+
offset: 16,
|
|
33
38
|
duration: 5000,
|
|
39
|
+
order: 'queue',
|
|
40
|
+
dim: false,
|
|
41
|
+
zIndex: 20,
|
|
34
42
|
},
|
|
35
43
|
argTypes: {
|
|
36
44
|
position: {
|
|
37
45
|
options: ['top', 'bottom'],
|
|
38
46
|
control: { type: 'inline-radio' },
|
|
47
|
+
table: { category: 'Hook', defaultValue: { summary: "'bottom'" } },
|
|
39
48
|
},
|
|
40
|
-
|
|
41
|
-
control: '
|
|
49
|
+
offset: {
|
|
50
|
+
control: { type: 'number', min: 0, step: 1 },
|
|
51
|
+
table: { category: 'Hook', defaultValue: { summary: '16' } },
|
|
42
52
|
},
|
|
43
53
|
duration: {
|
|
44
54
|
control: { type: 'number', min: 0, step: 500 },
|
|
45
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
|
+
dim: {
|
|
64
|
+
control: 'boolean',
|
|
65
|
+
table: { category: 'Hook', defaultValue: { summary: 'false' } },
|
|
66
|
+
},
|
|
67
|
+
zIndex: {
|
|
68
|
+
control: { type: 'number', min: 0, step: 1 },
|
|
69
|
+
table: { category: 'Hook', defaultValue: { summary: '20' } },
|
|
70
|
+
},
|
|
71
|
+
className: {
|
|
72
|
+
control: 'text',
|
|
73
|
+
table: { category: 'Hook' },
|
|
74
|
+
},
|
|
75
|
+
portalContainer: {
|
|
76
|
+
control: false,
|
|
77
|
+
table: { category: 'Hook' },
|
|
78
|
+
},
|
|
79
|
+
css: {
|
|
80
|
+
table: { disable: true },
|
|
81
|
+
},
|
|
82
|
+
message: {
|
|
83
|
+
control: 'text',
|
|
84
|
+
table: { category: 'Show' },
|
|
46
85
|
},
|
|
47
|
-
|
|
48
|
-
name: '
|
|
86
|
+
actionChildren: {
|
|
87
|
+
name: 'action',
|
|
49
88
|
control: 'text',
|
|
89
|
+
table: { category: 'Show' },
|
|
50
90
|
},
|
|
51
91
|
},
|
|
52
92
|
render: (args) => <SnackbarDemo {...args} />,
|
|
@@ -54,26 +94,24 @@ export default {
|
|
|
54
94
|
|
|
55
95
|
function SnackbarDemo({
|
|
56
96
|
message,
|
|
57
|
-
|
|
58
|
-
duration,
|
|
97
|
+
actionChildren,
|
|
59
98
|
...props
|
|
60
99
|
}: SnackbarStoryArgs) {
|
|
61
100
|
const [snackbar, showSnackbar] = unstable_useSnackbar(props)
|
|
62
|
-
const
|
|
63
|
-
const showOptions =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
101
|
+
const hasAction = actionChildren !== undefined && actionChildren !== ''
|
|
102
|
+
const showOptions = hasAction
|
|
103
|
+
? { action: <Button>{actionChildren}</Button> }
|
|
104
|
+
: undefined
|
|
67
105
|
|
|
68
106
|
useEffect(() => {
|
|
69
107
|
if (!defaultOpen) {
|
|
70
108
|
return
|
|
71
109
|
}
|
|
72
|
-
showSnackbar(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}, [
|
|
110
|
+
showSnackbar(
|
|
111
|
+
message,
|
|
112
|
+
hasAction ? { action: <Button>{actionChildren}</Button> } : undefined,
|
|
113
|
+
)
|
|
114
|
+
}, [actionChildren, hasAction, message, showSnackbar])
|
|
77
115
|
|
|
78
116
|
return (
|
|
79
117
|
<>
|
|
@@ -132,10 +170,10 @@ export const WithLineBreak: StoryObj<SnackbarStoryArgs> = {
|
|
|
132
170
|
},
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
export const
|
|
173
|
+
export const WithAction: StoryObj<SnackbarStoryArgs> = {
|
|
136
174
|
args: {
|
|
137
175
|
message: '保存しました',
|
|
138
|
-
|
|
176
|
+
actionChildren: '取り消す',
|
|
139
177
|
},
|
|
140
178
|
parameters: {
|
|
141
179
|
docs: {
|
|
@@ -152,9 +190,7 @@ export function Example() {
|
|
|
152
190
|
<Button
|
|
153
191
|
onClick={() =>
|
|
154
192
|
showSnackbar('保存しました', {
|
|
155
|
-
|
|
156
|
-
children: '取り消す',
|
|
157
|
-
},
|
|
193
|
+
action: <Button>取り消す</Button>,
|
|
158
194
|
})
|
|
159
195
|
}
|
|
160
196
|
>
|
|
@@ -179,6 +215,6 @@ export const Dim: StoryObj<SnackbarStoryArgs> = {
|
|
|
179
215
|
args: {
|
|
180
216
|
dim: true,
|
|
181
217
|
message: 'Dim の Snackbar',
|
|
182
|
-
|
|
218
|
+
actionChildren: '閉じる',
|
|
183
219
|
},
|
|
184
220
|
}
|
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, type ReactNode } from 'react'
|
|
2
2
|
import { render, fireEvent, act, cleanup, screen } from '@testing-library/react'
|
|
3
3
|
import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'
|
|
4
|
-
import Snackbar, { useSnackbar, type
|
|
4
|
+
import Snackbar, { useSnackbar, type ShowSnackbarOptions } from '.'
|
|
5
|
+
|
|
6
|
+
function renderSnackbar(props: Parameters<typeof useSnackbar>[0] = {}) {
|
|
7
|
+
let showSnackbar:
|
|
8
|
+
((message: ReactNode, options?: ShowSnackbarOptions) => void) | undefined
|
|
9
|
+
|
|
10
|
+
function SnackbarApp() {
|
|
11
|
+
const [snackbar, show] = useSnackbar(props)
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
showSnackbar = show
|
|
14
|
+
}, [show])
|
|
15
|
+
return snackbar
|
|
16
|
+
}
|
|
5
17
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
const result = render(<SnackbarApp />)
|
|
19
|
+
function show(message: ReactNode, options?: ShowSnackbarOptions) {
|
|
20
|
+
if (showSnackbar === undefined) throw new Error('showSnackbar not found')
|
|
21
|
+
const currentShowSnackbar = showSnackbar
|
|
10
22
|
act(() => {
|
|
11
|
-
|
|
23
|
+
currentShowSnackbar(message, options)
|
|
12
24
|
})
|
|
13
25
|
act(() => {
|
|
14
26
|
vi.advanceTimersByTime(300)
|
|
@@ -38,6 +50,25 @@ describe('Snackbar', () => {
|
|
|
38
50
|
vi.useRealTimers()
|
|
39
51
|
})
|
|
40
52
|
|
|
53
|
+
it('renders the message and required action', () => {
|
|
54
|
+
render(
|
|
55
|
+
<Snackbar
|
|
56
|
+
message="保存しました"
|
|
57
|
+
action={<button type="button">取り消す</button>}
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(screen.getByText('保存しました')).toBeInTheDocument()
|
|
62
|
+
expect(screen.getByRole('button', { name: '取り消す' })).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('requires a non-null action at the type level', () => {
|
|
66
|
+
// @ts-expect-error action must not be undefined
|
|
67
|
+
const undefinedAction = <Snackbar message="x" action={undefined} />
|
|
68
|
+
|
|
69
|
+
expect(undefinedAction).toBeDefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
41
72
|
it('shows a message and hides after 5 seconds', () => {
|
|
42
73
|
const { show } = renderSnackbar()
|
|
43
74
|
|
|
@@ -62,9 +93,9 @@ describe('Snackbar', () => {
|
|
|
62
93
|
})
|
|
63
94
|
|
|
64
95
|
it.each([0, -1])('clamps duration to zero: %s', (duration) => {
|
|
65
|
-
const { show } = renderSnackbar()
|
|
96
|
+
const { show } = renderSnackbar({ duration })
|
|
66
97
|
|
|
67
|
-
show('保存しました'
|
|
98
|
+
show('保存しました')
|
|
68
99
|
expect(screen.getByText('保存しました')).toBeInTheDocument()
|
|
69
100
|
|
|
70
101
|
act(() => {
|
|
@@ -84,9 +115,9 @@ describe('Snackbar', () => {
|
|
|
84
115
|
Number.NEGATIVE_INFINITY,
|
|
85
116
|
'invalid' as unknown as number,
|
|
86
117
|
])('falls back to the default duration: %s', (duration) => {
|
|
87
|
-
const { show } = renderSnackbar()
|
|
118
|
+
const { show } = renderSnackbar({ duration })
|
|
88
119
|
|
|
89
|
-
show('保存しました'
|
|
120
|
+
show('保存しました')
|
|
90
121
|
act(() => {
|
|
91
122
|
vi.advanceTimersByTime(4999)
|
|
92
123
|
})
|
|
@@ -120,6 +151,56 @@ describe('Snackbar', () => {
|
|
|
120
151
|
expect(screen.getByText('second')).toBeInTheDocument()
|
|
121
152
|
})
|
|
122
153
|
|
|
154
|
+
it('replaces the current snackbar immediately when requested', () => {
|
|
155
|
+
const onClose = vi.fn()
|
|
156
|
+
const { show } = renderSnackbar({ order: 'replace' })
|
|
157
|
+
|
|
158
|
+
show('first', { onClose })
|
|
159
|
+
show('second')
|
|
160
|
+
|
|
161
|
+
expect(screen.queryByText('first')).not.toBeInTheDocument()
|
|
162
|
+
expect(screen.getByText('second')).toBeInTheDocument()
|
|
163
|
+
expect(onClose).toHaveBeenCalledExactlyOnceWith('replaced')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('reports timeout when the snackbar closes after its duration', () => {
|
|
167
|
+
const onClose = vi.fn()
|
|
168
|
+
const { show } = renderSnackbar({ duration: 0 })
|
|
169
|
+
|
|
170
|
+
show('保存しました', { onClose })
|
|
171
|
+
act(() => {
|
|
172
|
+
vi.advanceTimersByTime(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(onClose).toHaveBeenCalledExactlyOnceWith('timeout')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reports action when the action is clicked', () => {
|
|
179
|
+
const onClose = vi.fn()
|
|
180
|
+
const { show } = renderSnackbar()
|
|
181
|
+
|
|
182
|
+
show('保存しました', {
|
|
183
|
+
action: <button type="button">取り消す</button>,
|
|
184
|
+
onClose,
|
|
185
|
+
})
|
|
186
|
+
fireEvent.click(screen.getByRole('button', { name: '取り消す' }))
|
|
187
|
+
|
|
188
|
+
expect(onClose).toHaveBeenCalledExactlyOnceWith('action')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('reports unmounted only for the currently displayed snackbar', () => {
|
|
192
|
+
const firstOnClose = vi.fn()
|
|
193
|
+
const secondOnClose = vi.fn()
|
|
194
|
+
const { show, unmount } = renderSnackbar()
|
|
195
|
+
|
|
196
|
+
show('first', { onClose: firstOnClose })
|
|
197
|
+
show('second', { onClose: secondOnClose })
|
|
198
|
+
unmount()
|
|
199
|
+
|
|
200
|
+
expect(firstOnClose).toHaveBeenCalledExactlyOnceWith('unmounted')
|
|
201
|
+
expect(secondOnClose).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
123
204
|
it('keeps the snackbar open while hovered after the timer ends, then closes on leave', () => {
|
|
124
205
|
const { show } = renderSnackbar()
|
|
125
206
|
|
|
@@ -163,11 +244,11 @@ describe('Snackbar', () => {
|
|
|
163
244
|
expect(snackbar).toHaveAttribute('data-exiting', 'true')
|
|
164
245
|
})
|
|
165
246
|
|
|
166
|
-
it('places a snackbar with
|
|
247
|
+
it('places a snackbar with an action at the bottom', () => {
|
|
167
248
|
const { show } = renderSnackbar({ position: 'top' })
|
|
168
249
|
|
|
169
250
|
show('保存しました', {
|
|
170
|
-
|
|
251
|
+
action: <button type="button">取り消す</button>,
|
|
171
252
|
})
|
|
172
253
|
|
|
173
254
|
expect(screen.getByRole('region')).toHaveAttribute(
|
|
@@ -176,7 +257,7 @@ describe('Snackbar', () => {
|
|
|
176
257
|
)
|
|
177
258
|
})
|
|
178
259
|
|
|
179
|
-
it('places a snackbar without
|
|
260
|
+
it('places a snackbar without an action at the top when requested', () => {
|
|
180
261
|
const { show } = renderSnackbar({ position: 'top' })
|
|
181
262
|
|
|
182
263
|
show('保存しました')
|
|
@@ -201,7 +282,7 @@ describe('Snackbar', () => {
|
|
|
201
282
|
const { show } = renderSnackbar()
|
|
202
283
|
|
|
203
284
|
show('保存しました', {
|
|
204
|
-
|
|
285
|
+
action: <button type="button">取り消す</button>,
|
|
205
286
|
})
|
|
206
287
|
|
|
207
288
|
fireEvent.keyDown(document, { key: 'F6' })
|
|
@@ -216,7 +297,7 @@ describe('Snackbar', () => {
|
|
|
216
297
|
trigger.focus()
|
|
217
298
|
|
|
218
299
|
show('保存しました', {
|
|
219
|
-
|
|
300
|
+
action: <button type="button">閉じる</button>,
|
|
220
301
|
})
|
|
221
302
|
|
|
222
303
|
fireEvent.keyDown(document, { key: 'F6' })
|
|
@@ -229,14 +310,14 @@ describe('Snackbar', () => {
|
|
|
229
310
|
trigger.remove()
|
|
230
311
|
})
|
|
231
312
|
|
|
232
|
-
it('closes on
|
|
313
|
+
it('closes on action click even while hovered', () => {
|
|
233
314
|
const { show } = renderSnackbar()
|
|
234
315
|
const trigger = document.createElement('button')
|
|
235
316
|
document.body.append(trigger)
|
|
236
317
|
trigger.focus()
|
|
237
318
|
|
|
238
319
|
show('保存しました', {
|
|
239
|
-
|
|
320
|
+
action: <button type="button">閉じる</button>,
|
|
240
321
|
})
|
|
241
322
|
|
|
242
323
|
const snackbar = screen
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import '../layout.css'
|
|
2
|
+
import './index.css'
|
|
3
|
+
|
|
4
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
|
5
|
+
import { useClassNames } from '../../../_lib/useClassNames'
|
|
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 SnackbarCloseReason =
|
|
12
|
+
'timeout' | 'replaced' | 'action' | 'close' | 'unmounted'
|
|
13
|
+
|
|
14
|
+
export type ShowSnackbarOptions = {
|
|
15
|
+
action?: ReactNode
|
|
16
|
+
onClose?: (reason: SnackbarCloseReason) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type SnackbarBaseProps = {
|
|
20
|
+
message: ReactNode
|
|
21
|
+
action?: ReactNode
|
|
22
|
+
dim?: boolean
|
|
23
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, 'children'>
|
|
24
|
+
|
|
25
|
+
export type SnackbarProps = Omit<SnackbarBaseProps, 'action'> & {
|
|
26
|
+
/** Snackbar の右側に表示するアクション */
|
|
27
|
+
action: NonNullable<ReactNode>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type UseSnackbarProps = Omit<NotificationProps, 'position'> & {
|
|
31
|
+
position?: Position
|
|
32
|
+
dim?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SnackbarContent = {
|
|
36
|
+
message: ReactNode
|
|
37
|
+
action?: ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const Snackbar = forwardRef<HTMLDivElement, SnackbarProps>(
|
|
41
|
+
function Snackbar(props, ref) {
|
|
42
|
+
return <SnackbarBase {...props} ref={ref} />
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
export default Snackbar
|
|
47
|
+
|
|
48
|
+
export function useSnackbar(props: UseSnackbarProps = {}) {
|
|
49
|
+
'use memo'
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
position = 'bottom',
|
|
53
|
+
dim = false,
|
|
54
|
+
duration,
|
|
55
|
+
order,
|
|
56
|
+
...regionProps
|
|
57
|
+
} = props
|
|
58
|
+
const { state, itemRef, enqueue, close, onHoverStart, onHoverEnd } =
|
|
59
|
+
useNotificationQueue<SnackbarContent, SnackbarCloseReason>('snackbar', {
|
|
60
|
+
duration,
|
|
61
|
+
order,
|
|
62
|
+
timeoutReason: 'timeout',
|
|
63
|
+
unmountedReason: 'unmounted',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function show(message: ReactNode, options: ShowSnackbarOptions = {}) {
|
|
67
|
+
enqueue({ message, action: options.action }, options.onClose)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hasAction = state.visibleToasts.some(
|
|
71
|
+
(toast) => toast.content.action !== undefined,
|
|
72
|
+
)
|
|
73
|
+
const effectivePosition = hasAction ? 'bottom' : position
|
|
74
|
+
const element = (
|
|
75
|
+
<NotificationRegion
|
|
76
|
+
name="snackbar"
|
|
77
|
+
state={state}
|
|
78
|
+
position={effectivePosition}
|
|
79
|
+
{...regionProps}
|
|
80
|
+
>
|
|
81
|
+
{state.visibleToasts.map((toast) => (
|
|
82
|
+
<NotificationItem
|
|
83
|
+
key={toast.key}
|
|
84
|
+
name="snackbar"
|
|
85
|
+
toast={toast}
|
|
86
|
+
state={state}
|
|
87
|
+
itemRef={itemRef}
|
|
88
|
+
onHoverStart={onHoverStart}
|
|
89
|
+
onHoverEnd={onHoverEnd}
|
|
90
|
+
data-dim={dim}
|
|
91
|
+
data-with-action={toast.content.action !== undefined}
|
|
92
|
+
>
|
|
93
|
+
{toast.content.action !== undefined && (
|
|
94
|
+
<div onClick={() => close(toast.key, 'action')}>
|
|
95
|
+
<SnackbarAction action={toast.content.action} />
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</NotificationItem>
|
|
99
|
+
))}
|
|
100
|
+
</NotificationRegion>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return [element, show] as const
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SnackbarBase = forwardRef<HTMLDivElement, SnackbarBaseProps>(
|
|
107
|
+
function SnackbarBase(
|
|
108
|
+
{ message, action, dim = false, className, ...rootProps },
|
|
109
|
+
ref,
|
|
110
|
+
) {
|
|
111
|
+
const classNames = useClassNames(
|
|
112
|
+
'charcoal-notification',
|
|
113
|
+
'charcoal-snackbar',
|
|
114
|
+
className,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
{...rootProps}
|
|
120
|
+
ref={ref}
|
|
121
|
+
className={classNames}
|
|
122
|
+
data-dim={dim}
|
|
123
|
+
data-with-action={action !== undefined}
|
|
124
|
+
>
|
|
125
|
+
<div role="status" className="charcoal-notification-content">
|
|
126
|
+
<div className="charcoal-notification-label">{message}</div>
|
|
127
|
+
</div>
|
|
128
|
+
{action !== undefined && <SnackbarAction action={action} />}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
function SnackbarAction({ action }: { action: ReactNode }) {
|
|
135
|
+
return <div>{action}</div>
|
|
136
|
+
}
|