@bug-on/md3-react 2.0.3 → 3.0.0
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/.turbo/turbo-build.log +33 -0
- package/CHANGELOG.md +55 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6127 -0
- package/dist/index.d.ts +6127 -71
- package/dist/index.js +1653 -614
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1566 -547
- package/dist/index.mjs.map +1 -1
- package/dist/material-symbols-cdn.css.d.ts +2 -0
- package/dist/material-symbols-self-hosted.css.d.ts +2 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +22 -19
- package/scripts/copy-assets.js +82 -0
- package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
- package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/loading-indicator.svg +19 -0
- package/src/assets/material-symbols-cdn.css +65 -0
- package/src/assets/material-symbols-self-hosted.css +90 -0
- package/src/css.d.ts +20 -0
- package/src/hooks/useClickOutside.ts +37 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/useRipple.ts +88 -0
- package/src/index.css +23 -0
- package/src/index.ts +349 -0
- package/src/lib/material-symbols-preconnect.tsx +82 -0
- package/src/lib/theme-utils.ts +180 -0
- package/src/lib/utils.ts +6 -0
- package/src/test/button.test.tsx +59 -0
- package/src/test/icon.test.tsx +91 -0
- package/src/test/loading-indicator.test.tsx +128 -0
- package/src/test/progress-indicator.test.tsx +306 -0
- package/src/test/setup.ts +80 -0
- package/src/test/typography.test.tsx +206 -0
- package/src/types/index.ts +7 -0
- package/src/types/md3.ts +31 -0
- package/src/ui/Text.tsx +60 -0
- package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
- package/src/ui/app-bar/app-bar-column.tsx +99 -0
- package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
- package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
- package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
- package/src/ui/app-bar/app-bar-row.tsx +104 -0
- package/src/ui/app-bar/app-bar.test.tsx +87 -0
- package/src/ui/app-bar/app-bar.tokens.ts +223 -0
- package/src/ui/app-bar/app-bar.types.ts +441 -0
- package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
- package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
- package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
- package/src/ui/app-bar/docked-toolbar.tsx +54 -0
- package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
- package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
- package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
- package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
- package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
- package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
- package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
- package/src/ui/app-bar/search-app-bar.tsx +176 -0
- package/src/ui/app-bar/search-view.tsx +227 -0
- package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
- package/src/ui/app-bar/small-app-bar.tsx +203 -0
- package/src/ui/badge.test.tsx +345 -0
- package/src/ui/badge.tsx +282 -0
- package/src/ui/button-group.test.tsx +71 -0
- package/src/ui/button-group.tsx +350 -0
- package/src/ui/button.test.tsx +297 -0
- package/src/ui/button.tsx +669 -0
- package/src/ui/card.test.tsx +187 -0
- package/src/ui/card.tsx +259 -0
- package/src/ui/checkbox.test.tsx +423 -0
- package/src/ui/checkbox.tsx +525 -0
- package/src/ui/chip.test.tsx +292 -0
- package/src/ui/chip.tsx +548 -0
- package/src/ui/code-block.tsx +219 -0
- package/src/ui/dialog.test.tsx +300 -0
- package/src/ui/dialog.tsx +384 -0
- package/src/ui/divider.test.tsx +314 -0
- package/src/ui/divider.tsx +412 -0
- package/src/ui/drawer.tsx +240 -0
- package/src/ui/fab-menu.test.tsx +494 -0
- package/src/ui/fab-menu.tsx +739 -0
- package/src/ui/fab.test.tsx +232 -0
- package/src/ui/fab.tsx +505 -0
- package/src/ui/icon-button.test.tsx +515 -0
- package/src/ui/icon-button.tsx +525 -0
- package/src/ui/icon.test.tsx +197 -0
- package/src/ui/icon.tsx +179 -0
- package/src/ui/loading-indicator.test.tsx +73 -0
- package/src/ui/loading-indicator.tsx +312 -0
- package/src/ui/menu/context-menu.tsx +275 -0
- package/src/ui/menu/index.ts +77 -0
- package/src/ui/menu/menu-animations.ts +102 -0
- package/src/ui/menu/menu-context.tsx +99 -0
- package/src/ui/menu/menu-divider.tsx +47 -0
- package/src/ui/menu/menu-group.tsx +200 -0
- package/src/ui/menu/menu-item.tsx +294 -0
- package/src/ui/menu/menu-tokens.ts +208 -0
- package/src/ui/menu/menu-types.ts +313 -0
- package/src/ui/menu/menu.test.tsx +624 -0
- package/src/ui/menu/menu.tsx +289 -0
- package/src/ui/menu/sub-menu.tsx +223 -0
- package/src/ui/menu/vertical-menu.tsx +382 -0
- package/src/ui/navigation-rail.test.tsx +404 -0
- package/src/ui/navigation-rail.tsx +604 -0
- package/src/ui/progress-indicator/circular.tsx +248 -0
- package/src/ui/progress-indicator/hooks.ts +51 -0
- package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
- package/src/ui/progress-indicator/linear-flat.tsx +83 -0
- package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
- package/src/ui/progress-indicator/linear.tsx +143 -0
- package/src/ui/progress-indicator/types.ts +158 -0
- package/src/ui/progress-indicator/utils.ts +73 -0
- package/src/ui/radio-button.test.tsx +407 -0
- package/src/ui/radio-button.tsx +551 -0
- package/src/ui/ripple.test.tsx +72 -0
- package/src/ui/ripple.tsx +234 -0
- package/src/ui/scroll-area.test.tsx +58 -0
- package/src/ui/scroll-area.tsx +139 -0
- package/src/ui/search/animated-placeholder.tsx +145 -0
- package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
- package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
- package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
- package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
- package/src/ui/search/index.ts +44 -0
- package/src/ui/search/search-bar.tsx +220 -0
- package/src/ui/search/search-context.tsx +42 -0
- package/src/ui/search/search-view-docked.tsx +194 -0
- package/src/ui/search/search-view-fullscreen.tsx +247 -0
- package/src/ui/search/search.test.tsx +233 -0
- package/src/ui/search/search.tokens.ts +134 -0
- package/src/ui/search/search.tsx +131 -0
- package/src/ui/search/search.types.ts +154 -0
- package/src/ui/search/trailing-action.tsx +49 -0
- package/src/ui/shared/constants.ts +122 -0
- package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
- package/src/ui/slider/hooks/useSliderMath.ts +195 -0
- package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
- package/src/ui/slider/range-slider.tsx +561 -0
- package/src/ui/slider/slider-thumb.tsx +379 -0
- package/src/ui/slider/slider-track.tsx +912 -0
- package/src/ui/slider/slider.tokens.ts +189 -0
- package/src/ui/slider/slider.tsx +259 -0
- package/src/ui/slider/slider.types.ts +288 -0
- package/src/ui/snackbar/index.ts +20 -0
- package/src/ui/snackbar/snackbar.test.tsx +338 -0
- package/src/ui/snackbar/snackbar.tsx +476 -0
- package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
- package/src/ui/switch/switch.stories.tsx +309 -0
- package/src/ui/switch/switch.test.tsx +243 -0
- package/src/ui/switch/switch.tokens.ts +89 -0
- package/src/ui/switch/switch.tsx +504 -0
- package/src/ui/switch/switch.types.ts +62 -0
- package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
- package/src/ui/tabs/tab.tsx +407 -0
- package/src/ui/tabs/tabs-content.tsx +89 -0
- package/src/ui/tabs/tabs-list.tsx +146 -0
- package/src/ui/tabs/tabs.test.tsx +290 -0
- package/src/ui/tabs/tabs.tokens.ts +121 -0
- package/src/ui/tabs/tabs.tsx +229 -0
- package/src/ui/tabs/tabs.types.ts +185 -0
- package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
- package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
- package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
- package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
- package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
- package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
- package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
- package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
- package/src/ui/text-field/text-field.test.tsx +454 -0
- package/src/ui/text-field/text-field.tokens.ts +104 -0
- package/src/ui/text-field/text-field.tsx +548 -0
- package/src/ui/text-field/text-field.types.ts +180 -0
- package/src/ui/theme-provider/index.tsx +190 -0
- package/src/ui/toc.test.tsx +108 -0
- package/src/ui/toc.tsx +172 -0
- package/src/ui/tooltip/plain-tooltip.tsx +63 -0
- package/src/ui/tooltip/rich-tooltip.tsx +94 -0
- package/src/ui/tooltip/tooltip-box.tsx +266 -0
- package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
- package/src/ui/tooltip/tooltip.tokens.ts +26 -0
- package/src/ui/tooltip/tooltip.types.ts +70 -0
- package/src/ui/tooltip/use-tooltip-position.ts +208 -0
- package/src/ui/tooltip/use-tooltip-state.ts +41 -0
- package/src/ui/typography/__tests__/typography.test.tsx +170 -0
- package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
- package/src/ui/typography/type-scale-tokens.ts +205 -0
- package/src/ui/typography/typography-key-tokens.ts +43 -0
- package/src/ui/typography/typography-tokens.ts +360 -0
- package/src/ui/typography/typography.css +22 -0
- package/src/ui/typography/typography.tsx +559 -0
- package/test-render.tsx +4 -0
- package/test-shadow.html +26 -0
- package/test_output.txt +164 -0
- package/test_output_v2.txt +5 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +20 -0
- package/vitest.config.ts +11 -0
- package/dist/hooks/useClickOutside.d.ts +0 -8
- package/dist/hooks/useMediaQuery.d.ts +0 -11
- package/dist/hooks/useRipple.d.ts +0 -26
- package/dist/lib/material-symbols-preconnect.d.ts +0 -42
- package/dist/lib/theme-utils.d.ts +0 -63
- package/dist/lib/utils.d.ts +0 -2
- package/dist/types/index.d.ts +0 -1
- package/dist/types/md3.d.ts +0 -14
- package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
- package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
- package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
- package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
- package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
- package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
- package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
- package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
- package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
- package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
- package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
- package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
- package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
- package/dist/ui/app-bar/search-view.d.ts +0 -54
- package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
- package/dist/ui/badge.d.ts +0 -125
- package/dist/ui/button-group.d.ts +0 -59
- package/dist/ui/button.d.ts +0 -148
- package/dist/ui/card.d.ts +0 -62
- package/dist/ui/checkbox.d.ts +0 -82
- package/dist/ui/chip.d.ts +0 -110
- package/dist/ui/code-block.d.ts +0 -14
- package/dist/ui/dialog.d.ts +0 -111
- package/dist/ui/divider.d.ts +0 -164
- package/dist/ui/drawer.d.ts +0 -39
- package/dist/ui/dropdown.d.ts +0 -29
- package/dist/ui/fab-menu.d.ts +0 -204
- package/dist/ui/fab.d.ts +0 -162
- package/dist/ui/icon-button.d.ts +0 -131
- package/dist/ui/icon.d.ts +0 -88
- package/dist/ui/loading-indicator.d.ts +0 -42
- package/dist/ui/navigation-rail.d.ts +0 -29
- package/dist/ui/progress-indicator/circular.d.ts +0 -3
- package/dist/ui/progress-indicator/hooks.d.ts +0 -3
- package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
- package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
- package/dist/ui/progress-indicator/linear.d.ts +0 -3
- package/dist/ui/progress-indicator/types.d.ts +0 -151
- package/dist/ui/progress-indicator/utils.d.ts +0 -3
- package/dist/ui/radio-button.d.ts +0 -106
- package/dist/ui/ripple.d.ts +0 -126
- package/dist/ui/scroll-area.d.ts +0 -27
- package/dist/ui/search/animated-placeholder.d.ts +0 -54
- package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
- package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
- package/dist/ui/search/index.d.ts +0 -27
- package/dist/ui/search/search-bar.d.ts +0 -32
- package/dist/ui/search/search-context.d.ts +0 -24
- package/dist/ui/search/search-view-docked.d.ts +0 -25
- package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
- package/dist/ui/search/search.d.ts +0 -50
- package/dist/ui/search/search.tokens.d.ts +0 -112
- package/dist/ui/search/search.types.d.ts +0 -131
- package/dist/ui/search/trailing-action.d.ts +0 -9
- package/dist/ui/shared/constants.d.ts +0 -86
- package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
- package/dist/ui/slider/range-slider.d.ts +0 -47
- package/dist/ui/slider/slider-thumb.d.ts +0 -33
- package/dist/ui/slider/slider-track.d.ts +0 -25
- package/dist/ui/slider/slider.d.ts +0 -60
- package/dist/ui/slider/slider.tokens.d.ts +0 -151
- package/dist/ui/slider/slider.types.d.ts +0 -259
- package/dist/ui/snackbar/index.d.ts +0 -6
- package/dist/ui/snackbar/snackbar.d.ts +0 -197
- package/dist/ui/switch/switch.d.ts +0 -30
- package/dist/ui/switch/switch.stories.d.ts +0 -48
- package/dist/ui/switch/switch.tokens.d.ts +0 -67
- package/dist/ui/switch/switch.types.d.ts +0 -59
- package/dist/ui/tabs/tab.d.ts +0 -43
- package/dist/ui/tabs/tabs-content.d.ts +0 -36
- package/dist/ui/tabs/tabs-list.d.ts +0 -40
- package/dist/ui/tabs/tabs.d.ts +0 -60
- package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
- package/dist/ui/tabs/tabs.types.d.ts +0 -172
- package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
- package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
- package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
- package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
- package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
- package/dist/ui/text-field/text-field.d.ts +0 -49
- package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
- package/dist/ui/text-field/text-field.types.d.ts +0 -126
- package/dist/ui/theme-provider/index.d.ts +0 -48
- package/dist/ui/toc.d.ts +0 -80
- package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
- package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
- package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
- package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
- package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
- package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
- package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
- package/dist/ui/typography/typography-tokens.d.ts +0 -220
- package/dist/ui/typography/typography.d.ts +0 -265
- /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
- /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file snackbar.tsx
|
|
3
|
+
*
|
|
4
|
+
* MD3 Expressive Snackbar component.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* - `Snackbar` → Pure display component (motion.div, role="status", aria-live="polite")
|
|
8
|
+
* - `SnackbarHost` → AnimatePresence container + queue flush (place once in layout)
|
|
9
|
+
* - `SnackbarProvider` → Context provider that wires SnackbarHost + exposes `useSnackbar`
|
|
10
|
+
* - `useSnackbarState` → Low-level ref-based queue hook (mutex pattern)
|
|
11
|
+
* - `useSnackbar` → Consumer hook for imperative `showSnackbar(visuals)` calls
|
|
12
|
+
*
|
|
13
|
+
* Queue strategy:
|
|
14
|
+
* - One snackbar visible at a time (MD3 spec).
|
|
15
|
+
* - Subsequent `showSnackbar()` calls are enqueued, shown as soon as current one dismisses.
|
|
16
|
+
* - Cleanup: on unmount, all pending promises resolve as 'dismissed'.
|
|
17
|
+
*
|
|
18
|
+
* @see https://m3.material.io/components/snackbar/overview
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
AnimatePresence,
|
|
23
|
+
domMax,
|
|
24
|
+
LazyMotion,
|
|
25
|
+
m,
|
|
26
|
+
useReducedMotion,
|
|
27
|
+
} from "motion/react";
|
|
28
|
+
import * as React from "react";
|
|
29
|
+
import { cn } from "../../lib/utils";
|
|
30
|
+
import { Icon } from "../icon";
|
|
31
|
+
import { IconButton } from "../icon-button";
|
|
32
|
+
|
|
33
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const DURATION_MAP = {
|
|
36
|
+
short: 4000,
|
|
37
|
+
long: 7000,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
const RESULT = {
|
|
41
|
+
ACTION: "action-performed",
|
|
42
|
+
DISMISSED: "dismissed",
|
|
43
|
+
} as const satisfies Record<string, SnackbarResult>;
|
|
44
|
+
|
|
45
|
+
// ─── Animation Config (Framer Motion — NOT CSS transitions) ──────────────────
|
|
46
|
+
|
|
47
|
+
const SNACKBAR_SPRING = {
|
|
48
|
+
type: "spring" as const,
|
|
49
|
+
bounce: 0.15,
|
|
50
|
+
duration: 0.4,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const SNACKBAR_ANIM = {
|
|
54
|
+
initial: { opacity: 0, y: 56, scale: 0.9 },
|
|
55
|
+
animate: {
|
|
56
|
+
opacity: 1,
|
|
57
|
+
y: 0,
|
|
58
|
+
scale: 1,
|
|
59
|
+
transition: SNACKBAR_SPRING,
|
|
60
|
+
},
|
|
61
|
+
exit: {
|
|
62
|
+
opacity: 0,
|
|
63
|
+
y: 24,
|
|
64
|
+
scale: 0.95,
|
|
65
|
+
transition: { duration: 0.2, ease: "easeIn" as const },
|
|
66
|
+
},
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
const REDUCED_MOTION_ANIM = {
|
|
70
|
+
initial: { opacity: 0 },
|
|
71
|
+
animate: { opacity: 1 },
|
|
72
|
+
exit: { opacity: 0 },
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Duration preset for the snackbar auto-dismiss timer.
|
|
79
|
+
* - `'short'` → 4 000 ms (default, MD3 spec)
|
|
80
|
+
* - `'long'` → 7 000 ms
|
|
81
|
+
* - `number` → custom milliseconds
|
|
82
|
+
*/
|
|
83
|
+
export type SnackbarDuration = "short" | "long" | number;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolution value returned by the `showSnackbar()` promise.
|
|
87
|
+
* - `'action-performed'` → user clicked the action button
|
|
88
|
+
* - `'dismissed'` → auto-dismissed or close button clicked
|
|
89
|
+
*/
|
|
90
|
+
export type SnackbarResult = "action-performed" | "dismissed";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Visual configuration for a single snackbar instance.
|
|
94
|
+
*/
|
|
95
|
+
export interface SnackbarVisuals {
|
|
96
|
+
/** Main message text. */
|
|
97
|
+
message: string;
|
|
98
|
+
/** Label for the optional action button. */
|
|
99
|
+
actionLabel?: string;
|
|
100
|
+
/** When `true`, renders a close (X) icon button. @default false */
|
|
101
|
+
withDismissAction?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* When `true`, renders the action button below the message (Column layout).
|
|
104
|
+
* Use when both message and actionLabel are long.
|
|
105
|
+
* @default false
|
|
106
|
+
*/
|
|
107
|
+
actionOnNewLine?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Auto-dismiss duration.
|
|
110
|
+
* @default 'short' (4 000 ms)
|
|
111
|
+
*/
|
|
112
|
+
duration?: SnackbarDuration;
|
|
113
|
+
/** Additional className applied to the snackbar container. */
|
|
114
|
+
className?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Internal runtime data for a currently-displayed snackbar.
|
|
119
|
+
* Includes the resolve callback to settle the caller's promise.
|
|
120
|
+
*/
|
|
121
|
+
export interface SnackbarData {
|
|
122
|
+
/** Unique key for AnimatePresence element diffing. */
|
|
123
|
+
id: string;
|
|
124
|
+
/** Visual configuration. */
|
|
125
|
+
visuals: SnackbarVisuals;
|
|
126
|
+
/** Settles the promise returned by `showSnackbar()`. */
|
|
127
|
+
resolve: (result: SnackbarResult) => void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Props for the pure `Snackbar` display component. */
|
|
131
|
+
export interface SnackbarProps {
|
|
132
|
+
/** Runtime data including message, actions, and resolve callback. */
|
|
133
|
+
data: SnackbarData;
|
|
134
|
+
/** Additional className merged onto the snackbar container. */
|
|
135
|
+
className?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Props for the `SnackbarHost` component. */
|
|
139
|
+
export interface SnackbarHostProps {
|
|
140
|
+
/** State returned by `useSnackbarState()`. */
|
|
141
|
+
state: UseSnackbarStateReturn;
|
|
142
|
+
/** Additional className applied to the fixed host wrapper. */
|
|
143
|
+
className?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Internal Queue Item ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
interface QueueItem {
|
|
149
|
+
visuals: SnackbarVisuals;
|
|
150
|
+
resolve: (result: SnackbarResult) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function resolveDuration(duration: SnackbarDuration | undefined): number {
|
|
156
|
+
if (duration === undefined) return DURATION_MAP.short;
|
|
157
|
+
if (typeof duration === "number") return duration;
|
|
158
|
+
return DURATION_MAP[duration];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function generateId(): string {
|
|
162
|
+
return typeof crypto !== "undefined" && crypto.randomUUID
|
|
163
|
+
? crypto.randomUUID()
|
|
164
|
+
: Math.random().toString(36).slice(2);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toSnackbarData(item: QueueItem): SnackbarData {
|
|
168
|
+
return { id: generateId(), visuals: item.visuals, resolve: item.resolve };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── useSnackbarState hook ────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** Return type of `useSnackbarState`. */
|
|
174
|
+
export interface UseSnackbarStateReturn {
|
|
175
|
+
/** Currently visible snackbar data, or `null` when idle. */
|
|
176
|
+
current: SnackbarData | null;
|
|
177
|
+
/**
|
|
178
|
+
* Show a snackbar with the given visuals.
|
|
179
|
+
* Returns a promise that resolves when the snackbar is dismissed or the action is triggered.
|
|
180
|
+
*/
|
|
181
|
+
showSnackbar: (visuals: SnackbarVisuals) => Promise<SnackbarResult>;
|
|
182
|
+
/** Internal dismiss handler — called by `SnackbarHost`. */
|
|
183
|
+
_dismiss: (result: SnackbarResult) => void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Low-level hook that manages the snackbar queue and current state.
|
|
188
|
+
*
|
|
189
|
+
* Uses a `ref`-based queue (mutex pattern) so that enqueueing never
|
|
190
|
+
* triggers a re-render storm — only the state transition does.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```tsx
|
|
194
|
+
* // Used internally by SnackbarProvider
|
|
195
|
+
* const state = useSnackbarState();
|
|
196
|
+
* return <SnackbarHost state={state} />;
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function useSnackbarState(): UseSnackbarStateReturn {
|
|
200
|
+
const [current, setCurrent] = React.useState<SnackbarData | null>(null);
|
|
201
|
+
const queueRef = React.useRef<QueueItem[]>([]);
|
|
202
|
+
|
|
203
|
+
const showSnackbar = React.useCallback(
|
|
204
|
+
(visuals: SnackbarVisuals): Promise<SnackbarResult> => {
|
|
205
|
+
return new Promise<SnackbarResult>((resolve) => {
|
|
206
|
+
const item: QueueItem = { visuals, resolve };
|
|
207
|
+
setCurrent((prev) => {
|
|
208
|
+
if (prev === null) return toSnackbarData(item);
|
|
209
|
+
queueRef.current.push(item);
|
|
210
|
+
return prev;
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
[],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const _dismiss = React.useCallback((result: SnackbarResult) => {
|
|
218
|
+
setCurrent((prev) => {
|
|
219
|
+
if (prev) prev.resolve(result);
|
|
220
|
+
const next = queueRef.current.shift();
|
|
221
|
+
return next ? toSnackbarData(next) : null;
|
|
222
|
+
});
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
return () => {
|
|
227
|
+
for (const item of queueRef.current) {
|
|
228
|
+
item.resolve(RESULT.DISMISSED);
|
|
229
|
+
}
|
|
230
|
+
queueRef.current = [];
|
|
231
|
+
};
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
return { current, showSnackbar, _dismiss };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Snackbar (Pure Display Component) ───────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* MD3 Expressive Snackbar — pure display component.
|
|
241
|
+
*
|
|
242
|
+
* Renders a single snackbar with message, optional action button, and
|
|
243
|
+
* optional dismiss icon button. Handles its own auto-dismiss timer.
|
|
244
|
+
*
|
|
245
|
+
* @remarks
|
|
246
|
+
* - Uses `role="status"` + `aria-live="polite"` for screen reader announcements.
|
|
247
|
+
* - All entrance/exit animation is handled by the parent `SnackbarHost` via
|
|
248
|
+
* `AnimatePresence` + `SNACKBAR_ANIM`.
|
|
249
|
+
* - Do NOT render this component directly — use `SnackbarHost`.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```tsx
|
|
253
|
+
* // Internal usage inside SnackbarHost — not for direct use
|
|
254
|
+
* <Snackbar data={currentSnackbarData} />
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export const Snackbar = React.memo(function Snackbar({
|
|
258
|
+
data,
|
|
259
|
+
className,
|
|
260
|
+
}: SnackbarProps) {
|
|
261
|
+
const { visuals, resolve } = data;
|
|
262
|
+
const {
|
|
263
|
+
message,
|
|
264
|
+
actionLabel,
|
|
265
|
+
withDismissAction = false,
|
|
266
|
+
actionOnNewLine = false,
|
|
267
|
+
duration,
|
|
268
|
+
} = visuals;
|
|
269
|
+
|
|
270
|
+
const reducedMotion = useReducedMotion();
|
|
271
|
+
const durationMs = resolveDuration(duration);
|
|
272
|
+
|
|
273
|
+
React.useEffect(() => {
|
|
274
|
+
const timer = setTimeout(() => resolve(RESULT.DISMISSED), durationMs);
|
|
275
|
+
return () => clearTimeout(timer);
|
|
276
|
+
}, [resolve, durationMs]);
|
|
277
|
+
|
|
278
|
+
const handleAction = React.useCallback(
|
|
279
|
+
() => resolve(RESULT.ACTION),
|
|
280
|
+
[resolve],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const handleDismiss = React.useCallback(
|
|
284
|
+
() => resolve(RESULT.DISMISSED),
|
|
285
|
+
[resolve],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const hasActions = actionLabel || withDismissAction;
|
|
289
|
+
const anim = reducedMotion ? REDUCED_MOTION_ANIM : SNACKBAR_ANIM;
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<m.div
|
|
293
|
+
role="status"
|
|
294
|
+
aria-live="polite"
|
|
295
|
+
aria-atomic="true"
|
|
296
|
+
{...anim}
|
|
297
|
+
className={cn(
|
|
298
|
+
"flex items-center gap-2",
|
|
299
|
+
"min-w-72 max-w-142 w-max",
|
|
300
|
+
"rounded-sm px-4 py-3 shadow-md",
|
|
301
|
+
"text-m3-inverse-on-surface bg-m3-inverse-surface",
|
|
302
|
+
actionOnNewLine ? "flex-col items-start" : "flex-row",
|
|
303
|
+
className,
|
|
304
|
+
visuals.className,
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
<span className="flex-1 text-sm leading-5 font-normal">{message}</span>
|
|
308
|
+
|
|
309
|
+
{hasActions && (
|
|
310
|
+
<div
|
|
311
|
+
className={cn(
|
|
312
|
+
"flex shrink-0 items-center gap-1",
|
|
313
|
+
actionOnNewLine && "self-end",
|
|
314
|
+
)}
|
|
315
|
+
>
|
|
316
|
+
{actionLabel && (
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
onClick={handleAction}
|
|
320
|
+
className={cn(
|
|
321
|
+
"text-sm font-medium",
|
|
322
|
+
"px-2 py-1 rounded-sm",
|
|
323
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
324
|
+
"transition-colors whitespace-nowrap",
|
|
325
|
+
"text-m3-inverse-primary",
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{actionLabel}
|
|
329
|
+
</button>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{withDismissAction && (
|
|
333
|
+
<IconButton
|
|
334
|
+
size="sm"
|
|
335
|
+
colorStyle="filled"
|
|
336
|
+
aria-label="Dismiss notification"
|
|
337
|
+
onClick={handleDismiss}
|
|
338
|
+
className="text-m3-inverse-on-surface bg-m3-inverse-surface"
|
|
339
|
+
>
|
|
340
|
+
<Icon name="close" aria-hidden="true" />
|
|
341
|
+
</IconButton>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</m.div>
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
Snackbar.displayName = "Snackbar";
|
|
350
|
+
|
|
351
|
+
// ─── SnackbarHost ─────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* MD3 SnackbarHost — renders the AnimatePresence container for snackbar queue.
|
|
355
|
+
*
|
|
356
|
+
* Place this once in your app layout. It will show snackbars one at a time,
|
|
357
|
+
* dequeuing the next one as each dismisses.
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* ```tsx
|
|
361
|
+
* // Typically used inside SnackbarProvider — not directly
|
|
362
|
+
* const state = useSnackbarState();
|
|
363
|
+
* <SnackbarHost state={state} />
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
export function SnackbarHost({ state, className }: SnackbarHostProps) {
|
|
367
|
+
const { current, _dismiss } = state;
|
|
368
|
+
|
|
369
|
+
const wrappedData = React.useMemo<SnackbarData | null>(() => {
|
|
370
|
+
if (!current) return null;
|
|
371
|
+
return { ...current, resolve: _dismiss };
|
|
372
|
+
}, [current, _dismiss]);
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<LazyMotion features={domMax} strict>
|
|
376
|
+
<section
|
|
377
|
+
aria-label="Snackbar notifications"
|
|
378
|
+
className={cn(
|
|
379
|
+
"fixed bottom-4 left-1/2 -translate-x-1/2 z-50",
|
|
380
|
+
"flex flex-col items-center pointer-events-none",
|
|
381
|
+
className,
|
|
382
|
+
)}
|
|
383
|
+
>
|
|
384
|
+
<AnimatePresence mode="wait">
|
|
385
|
+
{wrappedData && (
|
|
386
|
+
<div key={wrappedData.id} className="pointer-events-auto">
|
|
387
|
+
<Snackbar data={wrappedData} />
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</AnimatePresence>
|
|
391
|
+
</section>
|
|
392
|
+
</LazyMotion>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
SnackbarHost.displayName = "SnackbarHost";
|
|
397
|
+
|
|
398
|
+
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
interface SnackbarContextValue {
|
|
401
|
+
showSnackbar: (visuals: SnackbarVisuals) => Promise<SnackbarResult>;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const SnackbarContext = React.createContext<SnackbarContextValue | null>(
|
|
405
|
+
null,
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// ─── SnackbarProvider ─────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* MD3 SnackbarProvider — context provider for imperative snackbar API.
|
|
412
|
+
*
|
|
413
|
+
* Wrap your application (or a section of it) with this provider.
|
|
414
|
+
* Then use `useSnackbar()` in any descendant to show snackbars.
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```tsx
|
|
418
|
+
* // In your root layout:
|
|
419
|
+
* <SnackbarProvider>
|
|
420
|
+
* <App />
|
|
421
|
+
* </SnackbarProvider>
|
|
422
|
+
*
|
|
423
|
+
* // In any component:
|
|
424
|
+
* const { showSnackbar } = useSnackbar();
|
|
425
|
+
* await showSnackbar({ message: 'Saved!', actionLabel: 'Undo' });
|
|
426
|
+
* ```
|
|
427
|
+
*/
|
|
428
|
+
export function SnackbarProvider({ children }: { children: React.ReactNode }) {
|
|
429
|
+
const state = useSnackbarState();
|
|
430
|
+
|
|
431
|
+
const contextValue = React.useMemo<SnackbarContextValue>(
|
|
432
|
+
() => ({ showSnackbar: state.showSnackbar }),
|
|
433
|
+
[state.showSnackbar],
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<SnackbarContext.Provider value={contextValue}>
|
|
438
|
+
{children}
|
|
439
|
+
<SnackbarHost state={state} />
|
|
440
|
+
</SnackbarContext.Provider>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
SnackbarProvider.displayName = "SnackbarProvider";
|
|
445
|
+
|
|
446
|
+
// ─── useSnackbar hook ─────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Hook that returns the `showSnackbar` function from the nearest `SnackbarProvider`.
|
|
450
|
+
*
|
|
451
|
+
* @throws {Error} if used outside of a `SnackbarProvider`.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* ```tsx
|
|
455
|
+
* function SaveButton() {
|
|
456
|
+
* const { showSnackbar } = useSnackbar();
|
|
457
|
+
*
|
|
458
|
+
* const handleSave = async () => {
|
|
459
|
+
* const result = await showSnackbar({
|
|
460
|
+
* message: 'Changes saved',
|
|
461
|
+
* actionLabel: 'Undo',
|
|
462
|
+
* });
|
|
463
|
+
* if (result === 'action-performed') undoSave();
|
|
464
|
+
* };
|
|
465
|
+
*
|
|
466
|
+
* return <button onClick={handleSave}>Save</button>;
|
|
467
|
+
* }
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
export function useSnackbar(): SnackbarContextValue {
|
|
471
|
+
const ctx = React.useContext(SnackbarContext);
|
|
472
|
+
if (!ctx) {
|
|
473
|
+
throw new Error("useSnackbar must be used within a <SnackbarProvider>.");
|
|
474
|
+
}
|
|
475
|
+
return ctx;
|
|
476
|
+
}
|