@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.
Files changed (65) 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/index.cjs +4 -1
  34. package/dist/index.css +1315 -1250
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -2
  38. package/dist/layered.css +1315 -1250
  39. package/dist/layered.css.map +1 -1
  40. package/package.json +5 -5
  41. package/src/__tests__/css-output.test.ts +39 -6
  42. package/src/components/Notification/NotificationItem.tsx +51 -0
  43. package/src/components/Notification/NotificationRegion.tsx +76 -0
  44. package/src/components/{Snackbar → Notification/Snackbar}/__snapshots__/index.css.snap +1 -42
  45. package/src/components/{Snackbar → Notification/Snackbar}/index.css +1 -43
  46. package/src/components/{Snackbar → Notification/Snackbar}/index.story.tsx +52 -23
  47. package/src/components/{Snackbar → Notification/Snackbar}/index.test.tsx +61 -11
  48. package/src/components/Notification/Snackbar/index.tsx +131 -0
  49. package/src/components/Notification/Toast/__snapshots__/index.css.snap +62 -0
  50. package/src/components/Notification/Toast/index.css +61 -0
  51. package/src/components/Notification/Toast/index.story.tsx +174 -0
  52. package/src/components/Notification/Toast/index.test.tsx +263 -0
  53. package/src/components/Notification/Toast/index.tsx +86 -0
  54. package/src/components/Notification/__snapshots__/layout.css.snap +47 -0
  55. package/src/components/Notification/layout.css +46 -0
  56. package/src/components/Notification/types.ts +28 -0
  57. package/src/components/Notification/useNotificationQueue.ts +235 -0
  58. package/src/index.ts +14 -2
  59. package/dist/components/Snackbar/index.d.ts +0 -52
  60. package/dist/components/Snackbar/index.d.ts.map +0 -1
  61. package/dist/components/Snackbar/index2.cjs +0 -268
  62. package/dist/components/Snackbar/index2.cjs.map +0 -1
  63. package/dist/components/Snackbar/index2.js +0 -263
  64. package/dist/components/Snackbar/index2.js.map +0 -1
  65. 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-button='true'] {
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-button='true'] {
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,6 +1,6 @@
1
1
  import { useEffect, type ReactNode } from 'react'
2
2
  import { Meta, StoryObj } from '@storybook/react-vite'
3
- import Button from '../Button'
3
+ import Button from '../../Button'
4
4
  import unstable_Snackbar, {
5
5
  useSnackbar as unstable_useSnackbar,
6
6
  type SnackbarProps as unstable_SnackbarProps,
@@ -10,8 +10,7 @@ const defaultOpen = !!process.env.TEST
10
10
 
11
11
  type SnackbarStoryArgs = unstable_SnackbarProps & {
12
12
  message: ReactNode
13
- buttonChildren?: string
14
- duration?: number
13
+ actionChildren?: string
15
14
  }
16
15
 
17
16
  export default {
@@ -20,9 +19,12 @@ export default {
20
19
  parameters: {
21
20
  layout: 'centered',
22
21
  tokenVersion: 'v2',
22
+ controls: {
23
+ sort: 'requiredFirst',
24
+ },
23
25
  docs: {
24
26
  description: {
25
- component: `同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合はキューに積み、前のスナックバーが消えてから表示します。
27
+ component: `同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合は、\`order\` に応じてキューに積むか、表示中のスナックバーを置き換えます。
26
28
 
27
29
  別々の \`useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。`,
28
30
  },
@@ -30,23 +32,55 @@ export default {
30
32
  },
31
33
  args: {
32
34
  message: '保存しました',
33
- duration: 5000,
35
+ order: 'queue',
34
36
  },
35
37
  argTypes: {
36
38
  position: {
37
39
  options: ['top', 'bottom'],
38
40
  control: { type: 'inline-radio' },
41
+ table: { category: 'Hook' },
39
42
  },
40
- message: {
41
- control: 'text',
43
+ offset: {
44
+ control: { type: 'number', min: 0, step: 1 },
45
+ table: { category: 'Hook' },
42
46
  },
43
47
  duration: {
44
48
  control: { type: 'number', min: 0, step: 500 },
45
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' },
46
79
  },
47
- buttonChildren: {
48
- name: 'button.children',
80
+ actionChildren: {
81
+ name: 'action',
49
82
  control: 'text',
83
+ table: { category: 'Show' },
50
84
  },
51
85
  },
52
86
  render: (args) => <SnackbarDemo {...args} />,
@@ -54,15 +88,13 @@ export default {
54
88
 
55
89
  function SnackbarDemo({
56
90
  message,
57
- buttonChildren,
58
- duration,
91
+ actionChildren,
59
92
  ...props
60
93
  }: SnackbarStoryArgs) {
61
94
  const [snackbar, showSnackbar] = unstable_useSnackbar(props)
62
- const hasButton = buttonChildren !== undefined && buttonChildren !== ''
95
+ const hasAction = actionChildren !== undefined && actionChildren !== ''
63
96
  const showOptions = {
64
- duration,
65
- ...(hasButton ? { button: { children: buttonChildren } } : {}),
97
+ ...(hasAction ? { action: <Button>{actionChildren}</Button> } : {}),
66
98
  }
67
99
 
68
100
  useEffect(() => {
@@ -70,10 +102,9 @@ function SnackbarDemo({
70
102
  return
71
103
  }
72
104
  showSnackbar(message, {
73
- duration: 60_000,
74
- ...(hasButton ? { button: { children: buttonChildren } } : {}),
105
+ ...(hasAction ? { action: <Button>{actionChildren}</Button> } : {}),
75
106
  })
76
- }, [buttonChildren, hasButton, message, showSnackbar])
107
+ }, [actionChildren, hasAction, message, showSnackbar])
77
108
 
78
109
  return (
79
110
  <>
@@ -132,10 +163,10 @@ export const WithLineBreak: StoryObj<SnackbarStoryArgs> = {
132
163
  },
133
164
  }
134
165
 
135
- export const WithButton: StoryObj<SnackbarStoryArgs> = {
166
+ export const WithAction: StoryObj<SnackbarStoryArgs> = {
136
167
  args: {
137
168
  message: '保存しました',
138
- buttonChildren: '取り消す',
169
+ actionChildren: '取り消す',
139
170
  },
140
171
  parameters: {
141
172
  docs: {
@@ -152,9 +183,7 @@ export function Example() {
152
183
  <Button
153
184
  onClick={() =>
154
185
  showSnackbar('保存しました', {
155
- button: {
156
- children: '取り消す',
157
- },
186
+ action: <Button>取り消す</Button>,
158
187
  })
159
188
  }
160
189
  >
@@ -179,6 +208,6 @@ export const Dim: StoryObj<SnackbarStoryArgs> = {
179
208
  args: {
180
209
  dim: true,
181
210
  message: 'Dim の Snackbar',
182
- buttonChildren: '閉じる',
211
+ actionChildren: '閉じる',
183
212
  },
184
213
  }
@@ -62,9 +62,9 @@ describe('Snackbar', () => {
62
62
  })
63
63
 
64
64
  it.each([0, -1])('clamps duration to zero: %s', (duration) => {
65
- const { show } = renderSnackbar()
65
+ const { show } = renderSnackbar({ duration })
66
66
 
67
- show('保存しました', { duration })
67
+ show('保存しました')
68
68
  expect(screen.getByText('保存しました')).toBeInTheDocument()
69
69
 
70
70
  act(() => {
@@ -84,9 +84,9 @@ describe('Snackbar', () => {
84
84
  Number.NEGATIVE_INFINITY,
85
85
  'invalid' as unknown as number,
86
86
  ])('falls back to the default duration: %s', (duration) => {
87
- const { show } = renderSnackbar()
87
+ const { show } = renderSnackbar({ duration })
88
88
 
89
- show('保存しました', { duration })
89
+ show('保存しました')
90
90
  act(() => {
91
91
  vi.advanceTimersByTime(4999)
92
92
  })
@@ -120,6 +120,56 @@ describe('Snackbar', () => {
120
120
  expect(screen.getByText('second')).toBeInTheDocument()
121
121
  })
122
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
+
123
173
  it('keeps the snackbar open while hovered after the timer ends, then closes on leave', () => {
124
174
  const { show } = renderSnackbar()
125
175
 
@@ -163,11 +213,11 @@ describe('Snackbar', () => {
163
213
  expect(snackbar).toHaveAttribute('data-exiting', 'true')
164
214
  })
165
215
 
166
- it('places a snackbar with a button at the bottom', () => {
216
+ it('places a snackbar with an action at the bottom', () => {
167
217
  const { show } = renderSnackbar({ position: 'top' })
168
218
 
169
219
  show('保存しました', {
170
- button: { children: '取り消す' },
220
+ action: <button type="button">取り消す</button>,
171
221
  })
172
222
 
173
223
  expect(screen.getByRole('region')).toHaveAttribute(
@@ -176,7 +226,7 @@ describe('Snackbar', () => {
176
226
  )
177
227
  })
178
228
 
179
- it('places a snackbar without a button at the top when requested', () => {
229
+ it('places a snackbar without an action at the top when requested', () => {
180
230
  const { show } = renderSnackbar({ position: 'top' })
181
231
 
182
232
  show('保存しました')
@@ -201,7 +251,7 @@ describe('Snackbar', () => {
201
251
  const { show } = renderSnackbar()
202
252
 
203
253
  show('保存しました', {
204
- button: { children: '取り消す' },
254
+ action: <button type="button">取り消す</button>,
205
255
  })
206
256
 
207
257
  fireEvent.keyDown(document, { key: 'F6' })
@@ -216,7 +266,7 @@ describe('Snackbar', () => {
216
266
  trigger.focus()
217
267
 
218
268
  show('保存しました', {
219
- button: { children: '閉じる' },
269
+ action: <button type="button">閉じる</button>,
220
270
  })
221
271
 
222
272
  fireEvent.keyDown(document, { key: 'F6' })
@@ -229,14 +279,14 @@ describe('Snackbar', () => {
229
279
  trigger.remove()
230
280
  })
231
281
 
232
- it('closes on button click even while hovered', () => {
282
+ it('closes on action click even while hovered', () => {
233
283
  const { show } = renderSnackbar()
234
284
  const trigger = document.createElement('button')
235
285
  document.body.append(trigger)
236
286
  trigger.focus()
237
287
 
238
288
  show('保存しました', {
239
- button: { children: '閉じる' },
289
+ action: <button type="button">閉じる</button>,
240
290
  })
241
291
 
242
292
  const snackbar = screen
@@ -0,0 +1,131 @@
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 SnackbarCloseReason =
12
+ 'timeout' | 'replaced' | 'action' | 'close' | 'unmounted'
13
+
14
+ export type ShowSnackbarOptions = {
15
+ /**
16
+ * Snackbar の右側に表示するアクション
17
+ */
18
+ action?: ReactNode
19
+ /**
20
+ * Snackbar が閉じるときに呼び出される
21
+ */
22
+ onClose?: (reason: SnackbarCloseReason) => void
23
+ }
24
+
25
+ export type SnackbarHandler = {
26
+ show: (message: ReactNode, options?: ShowSnackbarOptions) => void
27
+ }
28
+
29
+ export type SnackbarProps = Omit<NotificationProps, 'position'> & {
30
+ /**
31
+ * Snackbar の表示位置。ボタン付きの場合は `bottom` に固定される
32
+ * @default 'bottom'
33
+ */
34
+ position?: Position
35
+ /**
36
+ * 暗い背景色
37
+ * @default false
38
+ */
39
+ dim?: boolean
40
+ }
41
+
42
+ type SnackbarContent = {
43
+ message: ReactNode
44
+ action?: ReactNode
45
+ }
46
+
47
+ const Snackbar = forwardRef<SnackbarHandler, SnackbarProps>(function Snackbar(
48
+ { position = 'bottom', dim = false, duration, order, ...regionProps },
49
+ ref,
50
+ ) {
51
+ 'use memo'
52
+
53
+ const { state, itemRef, enqueue, close, onHoverStart, onHoverEnd } =
54
+ useNotificationQueue<SnackbarContent, SnackbarCloseReason>('snackbar', {
55
+ duration,
56
+ order,
57
+ timeoutReason: 'timeout',
58
+ unmountedReason: 'unmounted',
59
+ })
60
+
61
+ function show(message: ReactNode, options: ShowSnackbarOptions = {}) {
62
+ enqueue(
63
+ {
64
+ message,
65
+ action: options.action,
66
+ },
67
+ options.onClose,
68
+ )
69
+ }
70
+
71
+ useImperativeHandle(ref, () => ({ show }))
72
+
73
+ // アクション付きの Snackbar は常に下部に表示される
74
+ const hasAction = state.visibleToasts.some(
75
+ (toast) => toast.content.action !== undefined,
76
+ )
77
+ const effectivePosition = hasAction ? 'bottom' : position
78
+
79
+ return (
80
+ <NotificationRegion
81
+ name="snackbar"
82
+ state={state}
83
+ position={effectivePosition}
84
+ {...regionProps}
85
+ >
86
+ {state.visibleToasts.map((toast) => (
87
+ <NotificationItem
88
+ key={toast.key}
89
+ name="snackbar"
90
+ toast={toast}
91
+ state={state}
92
+ itemRef={itemRef}
93
+ onHoverStart={onHoverStart}
94
+ onHoverEnd={onHoverEnd}
95
+ data-dim={dim}
96
+ data-with-action={toast.content.action !== undefined}
97
+ >
98
+ {toast.content.action !== undefined && (
99
+ <SnackbarAction
100
+ action={toast.content.action}
101
+ onClick={() => close(toast.key, 'action')}
102
+ />
103
+ )}
104
+ </NotificationItem>
105
+ ))}
106
+ </NotificationRegion>
107
+ )
108
+ })
109
+
110
+ export default Snackbar
111
+
112
+ export function useSnackbar(props: SnackbarProps = {}) {
113
+ 'use memo'
114
+
115
+ const snackbarHandlerRef = useRef<SnackbarHandler>(null)
116
+ const element = <Snackbar ref={snackbarHandlerRef} {...props} />
117
+ function show(message: ReactNode, options?: ShowSnackbarOptions) {
118
+ snackbarHandlerRef.current?.show(message, options)
119
+ }
120
+ return [element, show] as const
121
+ }
122
+
123
+ function SnackbarAction({
124
+ action,
125
+ onClick,
126
+ }: {
127
+ action: ReactNode
128
+ onClick: () => void
129
+ }) {
130
+ return <div onClick={onClick}>{action}</div>
131
+ }
@@ -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
+ }