@dashforge/tw 0.1.0-beta
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/.swcrc +29 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/components/AppShell/AppShell.d.ts +32 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/appShell.types.d.ts +62 -0
- package/dist/components/AppShell/appShell.types.d.ts.map +1 -0
- package/dist/components/AppShell/appShell.variants.d.ts +65 -0
- package/dist/components/AppShell/appShell.variants.d.ts.map +1 -0
- package/dist/components/Autocomplete/Autocomplete.d.ts +32 -0
- package/dist/components/Autocomplete/Autocomplete.d.ts.map +1 -0
- package/dist/components/Autocomplete/autocomplete.types.d.ts +214 -0
- package/dist/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
- package/dist/components/Autocomplete/autocomplete.variants.d.ts +214 -0
- package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +44 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/button.types.d.ts +66 -0
- package/dist/components/Button/button.types.d.ts.map +1 -0
- package/dist/components/Button/button.variants.d.ts +104 -0
- package/dist/components/Button/button.variants.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.d.ts +31 -0
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox/checkbox.types.d.ts +86 -0
- package/dist/components/Checkbox/checkbox.types.d.ts.map +1 -0
- package/dist/components/Checkbox/checkbox.variants.d.ts +109 -0
- package/dist/components/Checkbox/checkbox.variants.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
- package/dist/components/DateTimePicker/DateTimePicker.d.ts +49 -0
- package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
- package/dist/components/LeftNav/LeftNav.d.ts +34 -0
- package/dist/components/LeftNav/LeftNav.d.ts.map +1 -0
- package/dist/components/LeftNav/leftNav.types.d.ts +136 -0
- package/dist/components/LeftNav/leftNav.types.d.ts.map +1 -0
- package/dist/components/LeftNav/leftNav.variants.d.ts +143 -0
- package/dist/components/LeftNav/leftNav.variants.d.ts.map +1 -0
- package/dist/components/NumberField/NumberField.d.ts +18 -0
- package/dist/components/NumberField/NumberField.d.ts.map +1 -0
- package/dist/components/NumberField/numberField.types.d.ts +79 -0
- package/dist/components/NumberField/numberField.types.d.ts.map +1 -0
- package/dist/components/NumberField/numberField.variants.d.ts +169 -0
- package/dist/components/NumberField/numberField.variants.d.ts.map +1 -0
- package/dist/components/OTPField/OTPField.d.ts +25 -0
- package/dist/components/OTPField/OTPField.d.ts.map +1 -0
- package/dist/components/OTPField/otpField.types.d.ts +74 -0
- package/dist/components/OTPField/otpField.types.d.ts.map +1 -0
- package/dist/components/OTPField/otpField.variants.d.ts +105 -0
- package/dist/components/OTPField/otpField.variants.d.ts.map +1 -0
- package/dist/components/RadioGroup/RadioGroup.d.ts +35 -0
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/components/RadioGroup/radioGroup.types.d.ts +103 -0
- package/dist/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
- package/dist/components/RadioGroup/radioGroup.variants.d.ts +166 -0
- package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
- package/dist/components/Snackbar/Snackbar.d.ts +37 -0
- package/dist/components/Snackbar/Snackbar.d.ts.map +1 -0
- package/dist/components/Snackbar/snackbar.types.d.ts +86 -0
- package/dist/components/Snackbar/snackbar.types.d.ts.map +1 -0
- package/dist/components/Snackbar/snackbar.variants.d.ts +153 -0
- package/dist/components/Snackbar/snackbar.variants.d.ts.map +1 -0
- package/dist/components/Switch/Switch.d.ts +15 -0
- package/dist/components/Switch/Switch.d.ts.map +1 -0
- package/dist/components/Switch/switch.types.d.ts +47 -0
- package/dist/components/Switch/switch.types.d.ts.map +1 -0
- package/dist/components/Switch/switch.variants.d.ts +105 -0
- package/dist/components/Switch/switch.variants.d.ts.map +1 -0
- package/dist/components/TextField/TextField.d.ts +25 -0
- package/dist/components/TextField/TextField.d.ts.map +1 -0
- package/dist/components/TextField/textField.types.d.ts +72 -0
- package/dist/components/TextField/textField.types.d.ts.map +1 -0
- package/dist/components/TextField/textField.variants.d.ts +160 -0
- package/dist/components/TextField/textField.variants.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts +16 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -0
- package/dist/components/Textarea/textarea.types.d.ts +63 -0
- package/dist/components/Textarea/textarea.types.d.ts.map +1 -0
- package/dist/components/Textarea/textarea.variants.d.ts +197 -0
- package/dist/components/Textarea/textarea.variants.d.ts.map +1 -0
- package/dist/components/TopBar/TopBar.d.ts +33 -0
- package/dist/components/TopBar/TopBar.d.ts.map +1 -0
- package/dist/components/TopBar/topBar.types.d.ts +49 -0
- package/dist/components/TopBar/topBar.types.d.ts.map +1 -0
- package/dist/components/TopBar/topBar.variants.d.ts +79 -0
- package/dist/components/TopBar/topBar.variants.d.ts.map +1 -0
- package/dist/components/_shared/resolveValidationState.d.ts +42 -0
- package/dist/components/_shared/resolveValidationState.d.ts.map +1 -0
- package/dist/hooks/useAccessState.d.ts +37 -0
- package/dist/hooks/useAccessState.d.ts.map +1 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +4958 -0
- package/dist/src/components/AppShell/AppShell.d.ts +32 -0
- package/dist/src/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/src/components/AppShell/appShell.types.d.ts +62 -0
- package/dist/src/components/AppShell/appShell.types.d.ts.map +1 -0
- package/dist/src/components/AppShell/appShell.variants.d.ts +65 -0
- package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/Autocomplete.d.ts +32 -0
- package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/autocomplete.types.d.ts +214 -0
- package/dist/src/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
- package/dist/src/components/Autocomplete/autocomplete.variants.d.ts +214 -0
- package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
- package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
- package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
- package/dist/src/components/Button/Button.d.ts +44 -0
- package/dist/src/components/Button/Button.d.ts.map +1 -0
- package/dist/src/components/Button/button.types.d.ts +66 -0
- package/dist/src/components/Button/button.types.d.ts.map +1 -0
- package/dist/src/components/Button/button.variants.d.ts +104 -0
- package/dist/src/components/Button/button.variants.d.ts.map +1 -0
- package/dist/src/components/Checkbox/Checkbox.d.ts +31 -0
- package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/src/components/Checkbox/checkbox.types.d.ts +86 -0
- package/dist/src/components/Checkbox/checkbox.types.d.ts.map +1 -0
- package/dist/src/components/Checkbox/checkbox.variants.d.ts +109 -0
- package/dist/src/components/Checkbox/checkbox.variants.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
- package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
- package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +49 -0
- package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
- package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
- package/dist/src/components/LeftNav/LeftNav.d.ts +34 -0
- package/dist/src/components/LeftNav/LeftNav.d.ts.map +1 -0
- package/dist/src/components/LeftNav/leftNav.types.d.ts +136 -0
- package/dist/src/components/LeftNav/leftNav.types.d.ts.map +1 -0
- package/dist/src/components/LeftNav/leftNav.variants.d.ts +143 -0
- package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -0
- package/dist/src/components/NumberField/NumberField.d.ts +18 -0
- package/dist/src/components/NumberField/NumberField.d.ts.map +1 -0
- package/dist/src/components/NumberField/numberField.types.d.ts +79 -0
- package/dist/src/components/NumberField/numberField.types.d.ts.map +1 -0
- package/dist/src/components/NumberField/numberField.variants.d.ts +169 -0
- package/dist/src/components/NumberField/numberField.variants.d.ts.map +1 -0
- package/dist/src/components/OTPField/OTPField.d.ts +25 -0
- package/dist/src/components/OTPField/OTPField.d.ts.map +1 -0
- package/dist/src/components/OTPField/otpField.types.d.ts +74 -0
- package/dist/src/components/OTPField/otpField.types.d.ts.map +1 -0
- package/dist/src/components/OTPField/otpField.variants.d.ts +105 -0
- package/dist/src/components/OTPField/otpField.variants.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/RadioGroup.d.ts +35 -0
- package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/radioGroup.types.d.ts +103 -0
- package/dist/src/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
- package/dist/src/components/RadioGroup/radioGroup.variants.d.ts +166 -0
- package/dist/src/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
- package/dist/src/components/Snackbar/Snackbar.d.ts +37 -0
- package/dist/src/components/Snackbar/Snackbar.d.ts.map +1 -0
- package/dist/src/components/Snackbar/snackbar.types.d.ts +86 -0
- package/dist/src/components/Snackbar/snackbar.types.d.ts.map +1 -0
- package/dist/src/components/Snackbar/snackbar.variants.d.ts +153 -0
- package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -0
- package/dist/src/components/Switch/Switch.d.ts +15 -0
- package/dist/src/components/Switch/Switch.d.ts.map +1 -0
- package/dist/src/components/Switch/switch.types.d.ts +47 -0
- package/dist/src/components/Switch/switch.types.d.ts.map +1 -0
- package/dist/src/components/Switch/switch.variants.d.ts +105 -0
- package/dist/src/components/Switch/switch.variants.d.ts.map +1 -0
- package/dist/src/components/TextField/TextField.d.ts +25 -0
- package/dist/src/components/TextField/TextField.d.ts.map +1 -0
- package/dist/src/components/TextField/textField.types.d.ts +72 -0
- package/dist/src/components/TextField/textField.types.d.ts.map +1 -0
- package/dist/src/components/TextField/textField.variants.d.ts +160 -0
- package/dist/src/components/TextField/textField.variants.d.ts.map +1 -0
- package/dist/src/components/Textarea/Textarea.d.ts +16 -0
- package/dist/src/components/Textarea/Textarea.d.ts.map +1 -0
- package/dist/src/components/Textarea/textarea.types.d.ts +63 -0
- package/dist/src/components/Textarea/textarea.types.d.ts.map +1 -0
- package/dist/src/components/Textarea/textarea.variants.d.ts +197 -0
- package/dist/src/components/Textarea/textarea.variants.d.ts.map +1 -0
- package/dist/src/components/TopBar/TopBar.d.ts +33 -0
- package/dist/src/components/TopBar/TopBar.d.ts.map +1 -0
- package/dist/src/components/TopBar/topBar.types.d.ts +49 -0
- package/dist/src/components/TopBar/topBar.types.d.ts.map +1 -0
- package/dist/src/components/TopBar/topBar.variants.d.ts +79 -0
- package/dist/src/components/TopBar/topBar.variants.d.ts.map +1 -0
- package/dist/src/components/_shared/resolveValidationState.d.ts +42 -0
- package/dist/src/components/_shared/resolveValidationState.d.ts.map +1 -0
- package/dist/src/hooks/useAccessState.d.ts +37 -0
- package/dist/src/hooks/useAccessState.d.ts.map +1 -0
- package/dist/src/index.d.ts +82 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/cn.d.ts +26 -0
- package/dist/src/utils/cn.d.ts.map +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/utils/cn.d.ts +26 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/eslint.config.mjs +47 -0
- package/package.json +37 -0
- package/project.json +15 -0
- package/rollup.config.cjs +16 -0
- package/src/components/AppShell/AppShell.test.tsx +223 -0
- package/src/components/AppShell/AppShell.tsx +114 -0
- package/src/components/AppShell/appShell.types.ts +49 -0
- package/src/components/AppShell/appShell.variants.ts +48 -0
- package/src/components/Autocomplete/Autocomplete.perf.test.tsx +225 -0
- package/src/components/Autocomplete/Autocomplete.test.tsx +556 -0
- package/src/components/Autocomplete/Autocomplete.tsx +996 -0
- package/src/components/Autocomplete/autocomplete.types.ts +198 -0
- package/src/components/Autocomplete/autocomplete.variants.ts +135 -0
- package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +125 -0
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +231 -0
- package/src/components/Breadcrumbs/Breadcrumbs.tsx +203 -0
- package/src/components/Breadcrumbs/breadcrumbs.types.ts +87 -0
- package/src/components/Breadcrumbs/breadcrumbs.variants.ts +49 -0
- package/src/components/Button/Button.test.tsx +183 -0
- package/src/components/Button/Button.tsx +139 -0
- package/src/components/Button/button.types.ts +74 -0
- package/src/components/Button/button.variants.ts +159 -0
- package/src/components/Checkbox/Checkbox.test.tsx +146 -0
- package/src/components/Checkbox/Checkbox.tsx +228 -0
- package/src/components/Checkbox/checkbox.types.ts +88 -0
- package/src/components/Checkbox/checkbox.variants.ts +68 -0
- package/src/components/ConfirmDialog/ConfirmDialog.perf.test.tsx +172 -0
- package/src/components/ConfirmDialog/ConfirmDialog.test.tsx +311 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +252 -0
- package/src/components/ConfirmDialog/confirmDialog.types.ts +70 -0
- package/src/components/ConfirmDialog/confirmDialog.variants.ts +76 -0
- package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +153 -0
- package/src/components/DateTimePicker/DateTimePicker.test.tsx +204 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +319 -0
- package/src/components/DateTimePicker/dateTimePicker.types.ts +83 -0
- package/src/components/DateTimePicker/dateTimePicker.variants.ts +84 -0
- package/src/components/LeftNav/LeftNav.perf.test.tsx +172 -0
- package/src/components/LeftNav/LeftNav.test.tsx +302 -0
- package/src/components/LeftNav/LeftNav.tsx +330 -0
- package/src/components/LeftNav/leftNav.types.ts +120 -0
- package/src/components/LeftNav/leftNav.variants.ts +96 -0
- package/src/components/NumberField/NumberField.test.tsx +83 -0
- package/src/components/NumberField/NumberField.tsx +293 -0
- package/src/components/NumberField/numberField.types.ts +72 -0
- package/src/components/NumberField/numberField.variants.ts +94 -0
- package/src/components/OTPField/OTPField.test.tsx +121 -0
- package/src/components/OTPField/OTPField.tsx +250 -0
- package/src/components/OTPField/otpField.types.ts +58 -0
- package/src/components/OTPField/otpField.variants.ts +72 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +140 -0
- package/src/components/RadioGroup/RadioGroup.tsx +261 -0
- package/src/components/RadioGroup/radioGroup.types.ts +85 -0
- package/src/components/RadioGroup/radioGroup.variants.ts +87 -0
- package/src/components/Snackbar/Snackbar.perf.test.tsx +144 -0
- package/src/components/Snackbar/Snackbar.test.tsx +327 -0
- package/src/components/Snackbar/Snackbar.tsx +298 -0
- package/src/components/Snackbar/snackbar.types.ts +83 -0
- package/src/components/Snackbar/snackbar.variants.ts +72 -0
- package/src/components/Switch/Switch.test.tsx +131 -0
- package/src/components/Switch/Switch.tsx +158 -0
- package/src/components/Switch/switch.types.ts +36 -0
- package/src/components/Switch/switch.variants.ts +65 -0
- package/src/components/TextField/TextField.test.tsx +194 -0
- package/src/components/TextField/TextField.tsx +216 -0
- package/src/components/TextField/textField.types.ts +70 -0
- package/src/components/TextField/textField.variants.ts +87 -0
- package/src/components/Textarea/Textarea.test.tsx +75 -0
- package/src/components/Textarea/Textarea.tsx +190 -0
- package/src/components/Textarea/textarea.types.ts +60 -0
- package/src/components/Textarea/textarea.variants.ts +85 -0
- package/src/components/TopBar/TopBar.test.tsx +129 -0
- package/src/components/TopBar/TopBar.tsx +66 -0
- package/src/components/TopBar/topBar.types.ts +42 -0
- package/src/components/TopBar/topBar.variants.ts +38 -0
- package/src/components/_shared/resolveValidationState.ts +60 -0
- package/src/hooks/useAccessState.spec.tsx +161 -0
- package/src/hooks/useAccessState.ts +81 -0
- package/src/index.ts +191 -0
- package/src/utils/cn.spec.ts +16 -0
- package/src/utils/cn.ts +29 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +55 -0
- package/tsconfig.spec.json +34 -0
- package/vite.config.ts +25 -0
- package/vitest.config.mts +22 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
render,
|
|
6
|
+
screen,
|
|
7
|
+
fireEvent,
|
|
8
|
+
cleanup,
|
|
9
|
+
act,
|
|
10
|
+
} from '@testing-library/react';
|
|
11
|
+
import { SnackbarProvider, useSnackbar } from './Snackbar.js';
|
|
12
|
+
|
|
13
|
+
void React;
|
|
14
|
+
afterEach(() => cleanup());
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Test trigger — exposes a button per snackbar call. Each click
|
|
21
|
+
* dispatches with the given options.
|
|
22
|
+
*/
|
|
23
|
+
function Trigger({
|
|
24
|
+
options,
|
|
25
|
+
testid = 'trigger',
|
|
26
|
+
}: {
|
|
27
|
+
options: Parameters<ReturnType<typeof useSnackbar>['enqueue']>[0];
|
|
28
|
+
testid?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const { enqueue } = useSnackbar();
|
|
31
|
+
return (
|
|
32
|
+
<button data-testid={testid} onClick={() => enqueue(options)}>
|
|
33
|
+
enqueue
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('<SnackbarProvider> + useSnackbar()', () => {
|
|
39
|
+
describe('basic enqueue + render', () => {
|
|
40
|
+
it('renders nothing initially', () => {
|
|
41
|
+
render(
|
|
42
|
+
<SnackbarProvider>
|
|
43
|
+
<div />
|
|
44
|
+
</SnackbarProvider>
|
|
45
|
+
);
|
|
46
|
+
expect(screen.queryByRole('region', { name: 'Notifications' })).toBeTruthy();
|
|
47
|
+
expect(screen.queryByText(/./)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows a snackbar when enqueue() is called', () => {
|
|
51
|
+
render(
|
|
52
|
+
<SnackbarProvider>
|
|
53
|
+
<Trigger options={{ message: 'Saved!' }} />
|
|
54
|
+
</SnackbarProvider>
|
|
55
|
+
);
|
|
56
|
+
act(() => {
|
|
57
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
58
|
+
});
|
|
59
|
+
expect(screen.getByText('Saved!')).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders the close button by default', () => {
|
|
63
|
+
render(
|
|
64
|
+
<SnackbarProvider>
|
|
65
|
+
<Trigger options={{ message: 'Saved!' }} />
|
|
66
|
+
</SnackbarProvider>
|
|
67
|
+
);
|
|
68
|
+
act(() => {
|
|
69
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
70
|
+
});
|
|
71
|
+
expect(
|
|
72
|
+
screen.getByRole('button', { name: 'Dismiss notification' })
|
|
73
|
+
).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('hides the close button when showClose=false', () => {
|
|
77
|
+
render(
|
|
78
|
+
<SnackbarProvider>
|
|
79
|
+
<Trigger options={{ message: 'No close', showClose: false }} />
|
|
80
|
+
</SnackbarProvider>
|
|
81
|
+
);
|
|
82
|
+
act(() => {
|
|
83
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
84
|
+
});
|
|
85
|
+
expect(
|
|
86
|
+
screen.queryByRole('button', { name: 'Dismiss notification' })
|
|
87
|
+
).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('dismiss', () => {
|
|
92
|
+
it('removes the snackbar on close-button click', () => {
|
|
93
|
+
render(
|
|
94
|
+
<SnackbarProvider>
|
|
95
|
+
<Trigger options={{ message: 'Saved!', autoHideMs: 0 }} />
|
|
96
|
+
</SnackbarProvider>
|
|
97
|
+
);
|
|
98
|
+
act(() => {
|
|
99
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
100
|
+
});
|
|
101
|
+
expect(screen.getByText('Saved!')).toBeTruthy();
|
|
102
|
+
act(() => {
|
|
103
|
+
fireEvent.click(
|
|
104
|
+
screen.getByRole('button', { name: 'Dismiss notification' })
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
expect(screen.queryByText('Saved!')).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('dismissAll() clears every visible snackbar', () => {
|
|
111
|
+
function MultiTrigger() {
|
|
112
|
+
const { enqueue, dismissAll } = useSnackbar();
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<button
|
|
116
|
+
data-testid="add1"
|
|
117
|
+
onClick={() => enqueue({ message: 'A', autoHideMs: 0 })}
|
|
118
|
+
>
|
|
119
|
+
add A
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
data-testid="add2"
|
|
123
|
+
onClick={() => enqueue({ message: 'B', autoHideMs: 0 })}
|
|
124
|
+
>
|
|
125
|
+
add B
|
|
126
|
+
</button>
|
|
127
|
+
<button data-testid="clear" onClick={() => dismissAll()}>
|
|
128
|
+
clear
|
|
129
|
+
</button>
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
render(
|
|
134
|
+
<SnackbarProvider>
|
|
135
|
+
<MultiTrigger />
|
|
136
|
+
</SnackbarProvider>
|
|
137
|
+
);
|
|
138
|
+
act(() => {
|
|
139
|
+
fireEvent.click(screen.getByTestId('add1'));
|
|
140
|
+
fireEvent.click(screen.getByTestId('add2'));
|
|
141
|
+
});
|
|
142
|
+
expect(screen.getByText('A')).toBeTruthy();
|
|
143
|
+
expect(screen.getByText('B')).toBeTruthy();
|
|
144
|
+
act(() => {
|
|
145
|
+
fireEvent.click(screen.getByTestId('clear'));
|
|
146
|
+
});
|
|
147
|
+
expect(screen.queryByText('A')).toBeNull();
|
|
148
|
+
expect(screen.queryByText('B')).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('auto-dismiss', () => {
|
|
153
|
+
it('auto-dismisses after autoHideMs', () => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
render(
|
|
156
|
+
<SnackbarProvider>
|
|
157
|
+
<Trigger options={{ message: 'Auto', autoHideMs: 1000 }} />
|
|
158
|
+
</SnackbarProvider>
|
|
159
|
+
);
|
|
160
|
+
act(() => {
|
|
161
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
162
|
+
});
|
|
163
|
+
expect(screen.getByText('Auto')).toBeTruthy();
|
|
164
|
+
act(() => {
|
|
165
|
+
vi.advanceTimersByTime(1100);
|
|
166
|
+
});
|
|
167
|
+
expect(screen.queryByText('Auto')).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does NOT auto-dismiss when autoHideMs is 0', () => {
|
|
171
|
+
vi.useFakeTimers();
|
|
172
|
+
render(
|
|
173
|
+
<SnackbarProvider>
|
|
174
|
+
<Trigger options={{ message: 'Sticky', autoHideMs: 0 }} />
|
|
175
|
+
</SnackbarProvider>
|
|
176
|
+
);
|
|
177
|
+
act(() => {
|
|
178
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
vi.advanceTimersByTime(60_000);
|
|
182
|
+
});
|
|
183
|
+
expect(screen.getByText('Sticky')).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('action button', () => {
|
|
188
|
+
it('invokes the action callback and dismisses the snackbar', () => {
|
|
189
|
+
const action = vi.fn();
|
|
190
|
+
render(
|
|
191
|
+
<SnackbarProvider>
|
|
192
|
+
<Trigger
|
|
193
|
+
options={{
|
|
194
|
+
message: 'Undo?',
|
|
195
|
+
autoHideMs: 0,
|
|
196
|
+
action: { label: 'Undo', onClick: action },
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
</SnackbarProvider>
|
|
200
|
+
);
|
|
201
|
+
act(() => {
|
|
202
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
203
|
+
});
|
|
204
|
+
act(() => {
|
|
205
|
+
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
|
206
|
+
});
|
|
207
|
+
expect(action).toHaveBeenCalledOnce();
|
|
208
|
+
expect(screen.queryByText('Undo?')).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('de-dup (same id)', () => {
|
|
213
|
+
it('replaces an existing snackbar instead of stacking when ids match', () => {
|
|
214
|
+
function TwoCallsSameId() {
|
|
215
|
+
const { enqueue } = useSnackbar();
|
|
216
|
+
return (
|
|
217
|
+
<button
|
|
218
|
+
data-testid="t"
|
|
219
|
+
onClick={() => {
|
|
220
|
+
enqueue({ id: 'saved', message: 'Saved v1', autoHideMs: 0 });
|
|
221
|
+
enqueue({ id: 'saved', message: 'Saved v2', autoHideMs: 0 });
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
twice
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
render(
|
|
229
|
+
<SnackbarProvider>
|
|
230
|
+
<TwoCallsSameId />
|
|
231
|
+
</SnackbarProvider>
|
|
232
|
+
);
|
|
233
|
+
act(() => {
|
|
234
|
+
fireEvent.click(screen.getByTestId('t'));
|
|
235
|
+
});
|
|
236
|
+
// Only v2 should be visible.
|
|
237
|
+
expect(screen.queryByText('Saved v1')).toBeNull();
|
|
238
|
+
expect(screen.getByText('Saved v2')).toBeTruthy();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('maxVisible queueing', () => {
|
|
243
|
+
it('caps the visible count and promotes queued items on dismiss', () => {
|
|
244
|
+
vi.useFakeTimers();
|
|
245
|
+
function AddMany() {
|
|
246
|
+
const { enqueue, dismiss } = useSnackbar();
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
<button
|
|
250
|
+
data-testid="add-all"
|
|
251
|
+
onClick={() => {
|
|
252
|
+
enqueue({ id: 'a', message: 'A', autoHideMs: 0 });
|
|
253
|
+
enqueue({ id: 'b', message: 'B', autoHideMs: 0 });
|
|
254
|
+
enqueue({ id: 'c', message: 'C', autoHideMs: 0 });
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
all
|
|
258
|
+
</button>
|
|
259
|
+
<button
|
|
260
|
+
data-testid="kill-a"
|
|
261
|
+
onClick={() => dismiss('a')}
|
|
262
|
+
>
|
|
263
|
+
kill A
|
|
264
|
+
</button>
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
render(
|
|
269
|
+
<SnackbarProvider maxVisible={2}>
|
|
270
|
+
<AddMany />
|
|
271
|
+
</SnackbarProvider>
|
|
272
|
+
);
|
|
273
|
+
act(() => {
|
|
274
|
+
fireEvent.click(screen.getByTestId('add-all'));
|
|
275
|
+
});
|
|
276
|
+
expect(screen.getByText('A')).toBeTruthy();
|
|
277
|
+
expect(screen.getByText('B')).toBeTruthy();
|
|
278
|
+
expect(screen.queryByText('C')).toBeNull(); // queued
|
|
279
|
+
act(() => {
|
|
280
|
+
fireEvent.click(screen.getByTestId('kill-a'));
|
|
281
|
+
});
|
|
282
|
+
expect(screen.queryByText('A')).toBeNull();
|
|
283
|
+
expect(screen.getByText('B')).toBeTruthy();
|
|
284
|
+
expect(screen.getByText('C')).toBeTruthy(); // promoted
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('useSnackbar() outside provider', () => {
|
|
289
|
+
it('throws a helpful error', () => {
|
|
290
|
+
const Lone = () => {
|
|
291
|
+
useSnackbar();
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
295
|
+
expect(() => render(<Lone />)).toThrow(/useSnackbar/);
|
|
296
|
+
spy.mockRestore();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('a11y wiring', () => {
|
|
301
|
+
it('renders an aria-live="polite" region with label "Notifications"', () => {
|
|
302
|
+
render(
|
|
303
|
+
<SnackbarProvider>
|
|
304
|
+
<div />
|
|
305
|
+
</SnackbarProvider>
|
|
306
|
+
);
|
|
307
|
+
const region = screen.getByRole('region', { name: 'Notifications' });
|
|
308
|
+
expect(region.getAttribute('aria-live')).toBe('polite');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('severity rendering', () => {
|
|
313
|
+
(['info', 'success', 'warning', 'danger'] as const).forEach((sev) => {
|
|
314
|
+
it(`renders the ${sev} glyph`, () => {
|
|
315
|
+
render(
|
|
316
|
+
<SnackbarProvider>
|
|
317
|
+
<Trigger options={{ message: sev, severity: sev }} />
|
|
318
|
+
</SnackbarProvider>
|
|
319
|
+
);
|
|
320
|
+
act(() => {
|
|
321
|
+
fireEvent.click(screen.getByTestId('trigger'));
|
|
322
|
+
});
|
|
323
|
+
expect(screen.getByText(sev)).toBeTruthy();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { cn } from '../../utils/cn.js';
|
|
11
|
+
import { snackbarVariants } from './snackbar.variants.js';
|
|
12
|
+
import type {
|
|
13
|
+
SnackbarApi,
|
|
14
|
+
SnackbarOptions,
|
|
15
|
+
SnackbarProviderProps,
|
|
16
|
+
SnackbarRecord,
|
|
17
|
+
SnackbarSeverity,
|
|
18
|
+
} from './snackbar.types.js';
|
|
19
|
+
|
|
20
|
+
const SnackbarContext = createContext<SnackbarApi | null>(null);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `useSnackbar()` — returns `{ enqueue, dismiss, dismissAll }`.
|
|
24
|
+
*
|
|
25
|
+
* Throws if called outside `<SnackbarProvider>` — silent no-op would
|
|
26
|
+
* be a footgun for "Saved!" toasts that silently vanish.
|
|
27
|
+
*/
|
|
28
|
+
export function useSnackbar(): SnackbarApi {
|
|
29
|
+
const ctx = useContext(SnackbarContext);
|
|
30
|
+
if (ctx == null) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'[Snackbar] useSnackbar() must be called inside <SnackbarProvider>.'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return ctx;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Unicode glyphs for severities — keeps the bundle small (no icon dep)
|
|
40
|
+
* and stays accessible because consumers pass their own message text.
|
|
41
|
+
* The glyph is `aria-hidden`; the screen reader announces the message
|
|
42
|
+
* via the live region.
|
|
43
|
+
*/
|
|
44
|
+
const SEVERITY_GLYPH: Record<SnackbarSeverity, string> = {
|
|
45
|
+
info: 'ⓘ',
|
|
46
|
+
success: '✓',
|
|
47
|
+
warning: '⚠',
|
|
48
|
+
danger: '✕',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dashforge TW Snackbar provider — transient, stacked toast notifications.
|
|
53
|
+
*
|
|
54
|
+
* **Behaviour**:
|
|
55
|
+
* - `enqueue(opts)` returns the assigned `id`.
|
|
56
|
+
* - Items appear in a fixed-corner stack (`position` prop).
|
|
57
|
+
* - At most `maxVisible` (default 5) are shown — extras queue FIFO.
|
|
58
|
+
* - Each item auto-dismisses after `autoHideMs` (default 4000); set
|
|
59
|
+
* `0` for a persistent snackbar.
|
|
60
|
+
* - Re-enqueueing with the SAME `id` reuses the slot and resets the
|
|
61
|
+
* timer (de-dup pattern for repeated "Saved" toasts).
|
|
62
|
+
*
|
|
63
|
+
* **A11y**:
|
|
64
|
+
* - Container is an `aria-live="polite"` region (assistive techs
|
|
65
|
+
* announce new messages without stealing focus).
|
|
66
|
+
* - Severity icon is `aria-hidden` — the message text carries the
|
|
67
|
+
* semantic.
|
|
68
|
+
* - Close + action buttons are focusable via Tab; the visual stack
|
|
69
|
+
* order is the same as the announcement order.
|
|
70
|
+
*
|
|
71
|
+
* **Re-render contract**:
|
|
72
|
+
* - Provider re-renders ONLY when the visible set changes.
|
|
73
|
+
* - The `api` (returned by `useSnackbar()`) is stable across re-renders
|
|
74
|
+
* — `useCallback`'d with no deps, mutates a ref internally.
|
|
75
|
+
* - Consumers calling `useSnackbar()` get the same identity each time
|
|
76
|
+
* so they can safely pass `enqueue` to `useEffect` deps.
|
|
77
|
+
*/
|
|
78
|
+
export function SnackbarProvider(props: SnackbarProviderProps) {
|
|
79
|
+
const {
|
|
80
|
+
children,
|
|
81
|
+
position = 'bottom-right',
|
|
82
|
+
maxVisible = 5,
|
|
83
|
+
defaults,
|
|
84
|
+
slotProps,
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The set of CURRENTLY-RENDERED snackbars. Extras enqueued past
|
|
89
|
+
* `maxVisible` wait in `queueRef` until a slot frees up.
|
|
90
|
+
*/
|
|
91
|
+
const [visible, setVisible] = useState<SnackbarRecord[]>([]);
|
|
92
|
+
const queueRef = useRef<SnackbarRecord[]>([]);
|
|
93
|
+
|
|
94
|
+
/** Monotonic id seed + tick. */
|
|
95
|
+
const idSeqRef = useRef(0);
|
|
96
|
+
const tickSeqRef = useRef(0);
|
|
97
|
+
|
|
98
|
+
/** Map<id, timeoutId> for auto-dismiss timers — cleared on dismiss/replace. */
|
|
99
|
+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
100
|
+
new Map()
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/** Stable refs so `api` callbacks don't need to depend on state. */
|
|
104
|
+
const maxVisibleRef = useRef(maxVisible);
|
|
105
|
+
maxVisibleRef.current = maxVisible;
|
|
106
|
+
const defaultsRef = useRef(defaults);
|
|
107
|
+
defaultsRef.current = defaults;
|
|
108
|
+
|
|
109
|
+
const clearTimer = (id: string) => {
|
|
110
|
+
const t = timersRef.current.get(id);
|
|
111
|
+
if (t != null) {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
timersRef.current.delete(id);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `dismiss` — remove a snackbar, then attempt to promote a queued
|
|
119
|
+
* item into the freed slot.
|
|
120
|
+
*/
|
|
121
|
+
const dismiss = useCallback((id: string) => {
|
|
122
|
+
clearTimer(id);
|
|
123
|
+
setVisible((curr) => {
|
|
124
|
+
const next = curr.filter((s) => s.id !== id);
|
|
125
|
+
// Promote one from the queue if there's room.
|
|
126
|
+
if (next.length < maxVisibleRef.current && queueRef.current.length > 0) {
|
|
127
|
+
const promoted = queueRef.current.shift();
|
|
128
|
+
if (promoted != null) {
|
|
129
|
+
scheduleAutoDismiss(promoted);
|
|
130
|
+
return [...next, promoted];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Schedule the auto-dismiss timeout for a given record (if its
|
|
140
|
+
* `autoHideMs > 0`). Stable ref so it can be invoked from inside
|
|
141
|
+
* `dismiss` without a circular dep.
|
|
142
|
+
*/
|
|
143
|
+
const dismissRef = useRef(dismiss);
|
|
144
|
+
dismissRef.current = dismiss;
|
|
145
|
+
const scheduleAutoDismiss = useCallback((rec: SnackbarRecord) => {
|
|
146
|
+
const ms = rec.autoHideMs ?? defaultsRef.current?.autoHideMs ?? 4000;
|
|
147
|
+
if (ms <= 0) return;
|
|
148
|
+
clearTimer(rec.id);
|
|
149
|
+
const handle = setTimeout(() => dismissRef.current(rec.id), ms);
|
|
150
|
+
timersRef.current.set(rec.id, handle);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `enqueue` — show a new snackbar.
|
|
155
|
+
*
|
|
156
|
+
* De-dup contract: if `options.id` matches a CURRENTLY-VISIBLE or
|
|
157
|
+
* CURRENTLY-QUEUED entry, the existing entry's content is updated
|
|
158
|
+
* AND its timer is reset, instead of appending a duplicate.
|
|
159
|
+
*/
|
|
160
|
+
const enqueue = useCallback((options: SnackbarOptions): string => {
|
|
161
|
+
const id = options.id ?? `sb-${++idSeqRef.current}`;
|
|
162
|
+
const tick = ++tickSeqRef.current;
|
|
163
|
+
const rec: SnackbarRecord = {
|
|
164
|
+
...defaultsRef.current,
|
|
165
|
+
...options,
|
|
166
|
+
id,
|
|
167
|
+
tick,
|
|
168
|
+
};
|
|
169
|
+
// Reset timer if id already exists (de-dup).
|
|
170
|
+
clearTimer(id);
|
|
171
|
+
|
|
172
|
+
setVisible((curr) => {
|
|
173
|
+
const idx = curr.findIndex((s) => s.id === id);
|
|
174
|
+
if (idx >= 0) {
|
|
175
|
+
// Replace in-place, reset timer.
|
|
176
|
+
const next = [...curr];
|
|
177
|
+
next[idx] = rec;
|
|
178
|
+
scheduleAutoDismiss(rec);
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
// Drop the same-id entry from the queue if it's waiting.
|
|
182
|
+
queueRef.current = queueRef.current.filter((s) => s.id !== id);
|
|
183
|
+
if (curr.length < maxVisibleRef.current) {
|
|
184
|
+
scheduleAutoDismiss(rec);
|
|
185
|
+
return [...curr, rec];
|
|
186
|
+
}
|
|
187
|
+
// Stack is full — queue and wait.
|
|
188
|
+
queueRef.current.push(rec);
|
|
189
|
+
return curr;
|
|
190
|
+
});
|
|
191
|
+
return id;
|
|
192
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
/** `dismissAll` — clear everything (visible + queued + timers). */
|
|
196
|
+
const dismissAll = useCallback(() => {
|
|
197
|
+
timersRef.current.forEach(clearTimeout);
|
|
198
|
+
timersRef.current.clear();
|
|
199
|
+
queueRef.current = [];
|
|
200
|
+
setVisible([]);
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// Clean up any straggler timers on unmount.
|
|
204
|
+
useEffect(
|
|
205
|
+
() => () => {
|
|
206
|
+
timersRef.current.forEach(clearTimeout);
|
|
207
|
+
timersRef.current.clear();
|
|
208
|
+
},
|
|
209
|
+
[]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const api = useMemo<SnackbarApi>(
|
|
213
|
+
() => ({ enqueue, dismiss, dismissAll }),
|
|
214
|
+
[enqueue, dismiss, dismissAll]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const v = snackbarVariants({ position });
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<SnackbarContext.Provider value={api}>
|
|
221
|
+
{children}
|
|
222
|
+
<div
|
|
223
|
+
className={cn(v.container(), slotProps?.container?.className)}
|
|
224
|
+
role="region"
|
|
225
|
+
aria-label="Notifications"
|
|
226
|
+
aria-live="polite"
|
|
227
|
+
aria-atomic="false"
|
|
228
|
+
>
|
|
229
|
+
{visible.map((rec) => {
|
|
230
|
+
const sev = rec.severity ?? defaults?.severity ?? 'info';
|
|
231
|
+
const showClose =
|
|
232
|
+
rec.showClose ?? defaults?.showClose ?? true;
|
|
233
|
+
const itemClasses = snackbarVariants({
|
|
234
|
+
position,
|
|
235
|
+
severity: sev,
|
|
236
|
+
});
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
key={rec.id}
|
|
240
|
+
data-state="entered"
|
|
241
|
+
data-tick={rec.tick}
|
|
242
|
+
className={cn(
|
|
243
|
+
itemClasses.item(),
|
|
244
|
+
slotProps?.item?.className
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
<span
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
className={cn(
|
|
250
|
+
itemClasses.icon(),
|
|
251
|
+
slotProps?.icon?.className
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
{SEVERITY_GLYPH[sev]}
|
|
255
|
+
</span>
|
|
256
|
+
<div
|
|
257
|
+
className={cn(
|
|
258
|
+
itemClasses.message(),
|
|
259
|
+
slotProps?.message?.className
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{rec.message}
|
|
263
|
+
</div>
|
|
264
|
+
{rec.action != null && (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => {
|
|
268
|
+
rec.action?.onClick();
|
|
269
|
+
dismiss(rec.id);
|
|
270
|
+
}}
|
|
271
|
+
className={cn(
|
|
272
|
+
itemClasses.action(),
|
|
273
|
+
slotProps?.action?.className
|
|
274
|
+
)}
|
|
275
|
+
>
|
|
276
|
+
{rec.action.label}
|
|
277
|
+
</button>
|
|
278
|
+
)}
|
|
279
|
+
{showClose && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
aria-label="Dismiss notification"
|
|
283
|
+
onClick={() => dismiss(rec.id)}
|
|
284
|
+
className={cn(
|
|
285
|
+
itemClasses.closeButton(),
|
|
286
|
+
slotProps?.closeButton?.className
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
×
|
|
290
|
+
</button>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
})}
|
|
295
|
+
</div>
|
|
296
|
+
</SnackbarContext.Provider>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SnackbarVariants } from './snackbar.variants.js';
|
|
3
|
+
|
|
4
|
+
/** Visual severity — drives icon color + accent on the snackbar surface. */
|
|
5
|
+
export type SnackbarSeverity = 'info' | 'success' | 'warning' | 'danger';
|
|
6
|
+
|
|
7
|
+
/** Corner anchor for the snackbar stack. */
|
|
8
|
+
export type SnackbarPosition =
|
|
9
|
+
| 'top-left'
|
|
10
|
+
| 'top-center'
|
|
11
|
+
| 'top-right'
|
|
12
|
+
| 'bottom-left'
|
|
13
|
+
| 'bottom-center'
|
|
14
|
+
| 'bottom-right';
|
|
15
|
+
|
|
16
|
+
/** One snackbar instance — what `useSnackbar().enqueue()` accepts. */
|
|
17
|
+
export interface SnackbarOptions {
|
|
18
|
+
/** Required text/content. */
|
|
19
|
+
message: ReactNode;
|
|
20
|
+
/** Visual severity. @default 'info' */
|
|
21
|
+
severity?: SnackbarSeverity;
|
|
22
|
+
/**
|
|
23
|
+
* Auto-dismiss after this many ms. `0` / negative ⇒ persistent
|
|
24
|
+
* (only dismissed by the close button or `dismiss(id)`).
|
|
25
|
+
* @default 4000
|
|
26
|
+
*/
|
|
27
|
+
autoHideMs?: number;
|
|
28
|
+
/** Optional action button label + handler. */
|
|
29
|
+
action?: { label: ReactNode; onClick: () => void };
|
|
30
|
+
/** Per-instance close-button visibility. @default true */
|
|
31
|
+
showClose?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Stable identifier for de-duplication. If a snackbar with the same
|
|
34
|
+
* `id` is enqueued while already visible, the existing one is REUSED
|
|
35
|
+
* (auto-dismiss timer reset) instead of stacking a duplicate. Useful
|
|
36
|
+
* for "Saved" toasts triggered repeatedly from the same action.
|
|
37
|
+
*/
|
|
38
|
+
id?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Internal model — every enqueued snackbar gets a unique id + a tick. */
|
|
42
|
+
export interface SnackbarRecord extends SnackbarOptions {
|
|
43
|
+
id: string;
|
|
44
|
+
/**
|
|
45
|
+
* Monotonic creation tick, used to drive the entry CSS transition
|
|
46
|
+
* (the consumer's CSS can target `data-state="entered"` keyed by tick).
|
|
47
|
+
*/
|
|
48
|
+
tick: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SnackbarSlotProps {
|
|
52
|
+
container?: { className?: string };
|
|
53
|
+
item?: { className?: string };
|
|
54
|
+
icon?: { className?: string };
|
|
55
|
+
message?: { className?: string };
|
|
56
|
+
action?: { className?: string };
|
|
57
|
+
closeButton?: { className?: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** API exposed by `useSnackbar()`. */
|
|
61
|
+
export interface SnackbarApi {
|
|
62
|
+
/** Show a snackbar — returns its assigned id (auto-generated if not provided). */
|
|
63
|
+
enqueue: (options: SnackbarOptions) => string;
|
|
64
|
+
/** Dismiss a specific snackbar. */
|
|
65
|
+
dismiss: (id: string) => void;
|
|
66
|
+
/** Dismiss every visible snackbar. */
|
|
67
|
+
dismissAll: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SnackbarProviderProps extends SnackbarVariants {
|
|
71
|
+
children: ReactNode;
|
|
72
|
+
/** Corner the stack anchors to. @default 'bottom-right' */
|
|
73
|
+
position?: SnackbarPosition;
|
|
74
|
+
/**
|
|
75
|
+
* Hard cap on simultaneously visible snackbars. Excess enqueued
|
|
76
|
+
* items wait FIFO until a slot frees up. @default 5
|
|
77
|
+
*/
|
|
78
|
+
maxVisible?: number;
|
|
79
|
+
/** Defaults merged into every enqueue. */
|
|
80
|
+
defaults?: Pick<SnackbarOptions, 'severity' | 'autoHideMs' | 'showClose'>;
|
|
81
|
+
/** Per-slot className overrides. */
|
|
82
|
+
slotProps?: SnackbarSlotProps;
|
|
83
|
+
}
|