@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
@@ -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,52 +1,92 @@
1
1
  import { useEffect, type ReactNode } from 'react'
2
2
  import { Meta, StoryObj } from '@storybook/react-vite'
3
- import Button from '../Button'
4
- import unstable_Snackbar, {
5
- useSnackbar as unstable_useSnackbar,
6
- type SnackbarProps as unstable_SnackbarProps,
7
- } from '.'
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 = unstable_SnackbarProps & {
8
+ type SnackbarStoryArgs = NonNullable<
9
+ Parameters<typeof unstable_useSnackbar>[0]
10
+ > & {
12
11
  message: ReactNode
13
- buttonChildren?: string
14
- duration?: number
12
+ actionChildren?: string
15
13
  }
16
14
 
17
15
  export default {
18
- title: 'react/unstable_Snackbar',
19
- component: unstable_Snackbar,
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: `同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合はキューに積み、前のスナックバーが消えてから表示します。
26
+ component: `\`unstable_useSnackbar\` を使用すると、表示時間やキューを含む表示制御を利用できます。同時に表示できるスナックバーは1つのみです。表示中に別のスナックバーが発生した場合は、\`order\` に応じてキューに積むか、表示中のスナックバーを置き換えます。
27
+
28
+ 別々の \`unstable_useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。
26
29
 
27
- 別々の \`useSnackbar\` を呼び出した場合は互いに独立するため、同時表示数は1件に制限されません。`,
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
- message: {
41
- control: 'text',
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
- buttonChildren: {
48
- name: 'button.children',
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
- buttonChildren,
58
- duration,
97
+ actionChildren,
59
98
  ...props
60
99
  }: SnackbarStoryArgs) {
61
100
  const [snackbar, showSnackbar] = unstable_useSnackbar(props)
62
- const hasButton = buttonChildren !== undefined && buttonChildren !== ''
63
- const showOptions = {
64
- duration,
65
- ...(hasButton ? { button: { children: buttonChildren } } : {}),
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(message, {
73
- duration: 60_000,
74
- ...(hasButton ? { button: { children: buttonChildren } } : {}),
75
- })
76
- }, [buttonChildren, hasButton, message, showSnackbar])
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 WithButton: StoryObj<SnackbarStoryArgs> = {
173
+ export const WithAction: StoryObj<SnackbarStoryArgs> = {
136
174
  args: {
137
175
  message: '保存しました',
138
- buttonChildren: '取り消す',
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
- button: {
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
- buttonChildren: '閉じる',
218
+ actionChildren: '閉じる',
183
219
  },
184
220
  }
@@ -1,14 +1,26 @@
1
- import { createRef, type ComponentProps } from 'react'
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 SnackbarHandler } from '.'
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
- function renderSnackbar(props: ComponentProps<typeof Snackbar> = {}) {
7
- const ref = createRef<SnackbarHandler>()
8
- const result = render(<Snackbar ref={ref} {...props} />)
9
- const show: SnackbarHandler['show'] = (message, options) => {
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
- ref.current?.show(message, options)
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('保存しました', { duration })
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('保存しました', { duration })
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 a button at the bottom', () => {
247
+ it('places a snackbar with an action at the bottom', () => {
167
248
  const { show } = renderSnackbar({ position: 'top' })
168
249
 
169
250
  show('保存しました', {
170
- button: { children: '取り消す' },
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 a button at the top when requested', () => {
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
- button: { children: '取り消す' },
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
- button: { children: '閉じる' },
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 button click even while hovered', () => {
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
- button: { children: '閉じる' },
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
+ }