@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,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file fab-menu.tsx
|
|
3
|
+
*
|
|
4
|
+
* Component FAB Menu theo phong cách MD3 Expressive.
|
|
5
|
+
*
|
|
6
|
+
* Cung cấp một Floating Action Button (FAB) dạng toggle để mở một danh sách các hành động có hiệu ứng stagger (xếp tầng).
|
|
7
|
+
* Tuân thủ mô hình FloatingActionButtonMenu của MD3 với sự hỗ trợ tiếp cận (accessibility) đầy đủ
|
|
8
|
+
* (điều hướng bàn phím, quản lý focus, các vai trò ARIA).
|
|
9
|
+
*
|
|
10
|
+
* @see https://m3.material.io/components/floating-action-button/overview
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AnimatePresence,
|
|
15
|
+
domMax,
|
|
16
|
+
LazyMotion,
|
|
17
|
+
m,
|
|
18
|
+
useReducedMotion,
|
|
19
|
+
useSpring,
|
|
20
|
+
useTransform,
|
|
21
|
+
} from "motion/react";
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import { cn } from "../lib/utils";
|
|
24
|
+
import { Ripple, useRippleState } from "./ripple";
|
|
25
|
+
import { SPRING_TRANSITION, SPRING_TRANSITION_FAST } from "./shared/constants";
|
|
26
|
+
import { TouchTarget } from "./shared/touch-target";
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Design Tokens — MD3 FAB Menu Spec
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const SPRING_NORMAL = { stiffness: 700, damping: 40 } as const;
|
|
33
|
+
const SPRING_REDUCED = { stiffness: 10000, damping: 100 } as const;
|
|
34
|
+
const FOCUS_DELAY_MS = 50;
|
|
35
|
+
|
|
36
|
+
const TOGGLE_FAB_COLORS: Record<
|
|
37
|
+
string,
|
|
38
|
+
{
|
|
39
|
+
containerBg: string;
|
|
40
|
+
containerText: string;
|
|
41
|
+
checkedBg: string;
|
|
42
|
+
checkedText: string;
|
|
43
|
+
}
|
|
44
|
+
> = {
|
|
45
|
+
primary: {
|
|
46
|
+
containerBg: "bg-m3-primary-container",
|
|
47
|
+
containerText: "text-m3-on-primary-container",
|
|
48
|
+
checkedBg: "bg-m3-primary",
|
|
49
|
+
checkedText: "text-m3-on-primary",
|
|
50
|
+
},
|
|
51
|
+
secondary: {
|
|
52
|
+
containerBg: "bg-m3-secondary-container",
|
|
53
|
+
containerText: "text-m3-on-secondary-container",
|
|
54
|
+
checkedBg: "bg-m3-secondary",
|
|
55
|
+
checkedText: "text-m3-on-secondary",
|
|
56
|
+
},
|
|
57
|
+
tertiary: {
|
|
58
|
+
containerBg: "bg-m3-tertiary-container",
|
|
59
|
+
containerText: "text-m3-on-tertiary-container",
|
|
60
|
+
checkedBg: "bg-m3-tertiary",
|
|
61
|
+
checkedText: "text-m3-on-tertiary",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Size tokens for ToggleFAB.
|
|
67
|
+
*
|
|
68
|
+
* MD3 Kotlin reference:
|
|
69
|
+
* - Baseline: 56dp, cornerRadius 16dp → 28dp (fully round)
|
|
70
|
+
* - Medium: ~80dp (FabMediumTokens.ContainerHeight), cornerRadius 20dp → 40dp
|
|
71
|
+
* - Large: 96dp, cornerRadius 28dp → 48dp
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
const TOGGLE_FAB_SIZES: Record<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
sizeClass: string;
|
|
78
|
+
initialRadius: number;
|
|
79
|
+
finalRadius: number;
|
|
80
|
+
}
|
|
81
|
+
> = {
|
|
82
|
+
baseline: { sizeClass: "h-14 w-14", initialRadius: 16, finalRadius: 28 },
|
|
83
|
+
medium: { sizeClass: "h-20 w-20", initialRadius: 20, finalRadius: 40 },
|
|
84
|
+
large: { sizeClass: "h-24 w-24", initialRadius: 28, finalRadius: 48 },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const MENU_ITEM_STYLES = {
|
|
88
|
+
padding: "ps-4 pe-6",
|
|
89
|
+
gap: "gap-3",
|
|
90
|
+
size: "h-14 min-w-14",
|
|
91
|
+
cornerRadius: 999,
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
const MENU_ITEM_COLORS: Record<string, { bg: string; text: string }> = {
|
|
95
|
+
primary: {
|
|
96
|
+
bg: "bg-m3-primary-container",
|
|
97
|
+
text: "text-m3-on-primary-container",
|
|
98
|
+
},
|
|
99
|
+
secondary: {
|
|
100
|
+
bg: "bg-m3-secondary-container",
|
|
101
|
+
text: "text-m3-on-secondary-container",
|
|
102
|
+
},
|
|
103
|
+
tertiary: {
|
|
104
|
+
bg: "bg-m3-tertiary-container",
|
|
105
|
+
text: "text-m3-on-tertiary-container",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const ALIGNMENT_CONTAINER_CLASSES: Record<string, string> = {
|
|
110
|
+
end: "items-end bottom-4 right-4 sm:bottom-6 sm:right-6",
|
|
111
|
+
start: "items-start bottom-4 left-4 sm:bottom-6 sm:left-6",
|
|
112
|
+
center: "items-center bottom-4 left-1/2 -translate-x-1/2 sm:bottom-6",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const ALIGNMENT_ITEMS_CLASSES: Record<string, string> = {
|
|
116
|
+
end: "items-end",
|
|
117
|
+
start: "items-start",
|
|
118
|
+
center: "items-center",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const ALIGNMENT_TRANSFORM_ORIGIN: Record<string, string> = {
|
|
122
|
+
end: "right",
|
|
123
|
+
start: "left",
|
|
124
|
+
center: "bottom",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Types
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Định dạng dữ liệu cho từng hành động (item) trong biểu mẫu menu của FAB.
|
|
133
|
+
*
|
|
134
|
+
* Nếu bỏ qua thuộc tính `label`, item sẽ render dạng ô vuông chỉ có icon.
|
|
135
|
+
*/
|
|
136
|
+
export interface FABMenuItemData {
|
|
137
|
+
/** Một ID duy nhất để dùng cho key react và quản lý focus. */
|
|
138
|
+
id: string;
|
|
139
|
+
/** Label hiển thị cạnh icon (tuỳ chọn). Không thêm thuộc tính này nếu muốn hiển thị chỉ có icon (icon-only). */
|
|
140
|
+
label?: string;
|
|
141
|
+
/** Node của Icon — thường là một component SVG Icon duy nhất. */
|
|
142
|
+
icon: React.ReactNode;
|
|
143
|
+
/** Gọi hàm ngay lập tức khi item được kích hoạt (click hoặc nhấn Enter/Phím cách). */
|
|
144
|
+
onClick: () => void;
|
|
145
|
+
/**
|
|
146
|
+
* Khi `true`, vô hiệu hoá item về mặt hình thức lẫn tương tác rẽ nhánh.
|
|
147
|
+
* Vẫn dùng `aria-disabled` thay vì HTML `disabled` nhằm giữ nó lấy được focus phục vụ cho accessibility.
|
|
148
|
+
* @default false
|
|
149
|
+
*/
|
|
150
|
+
disabled?: boolean;
|
|
151
|
+
/** Thêm CSS classes bổ sung dùng cho wrapper chính của item. */
|
|
152
|
+
className?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Các props điều khiển Component chính `FABMenu`.
|
|
157
|
+
*
|
|
158
|
+
* @remarks
|
|
159
|
+
* FABMenu quản lý vòng đời trạng thái mở/đóng menu (open/close), quản lý focus, điều khiển phím,
|
|
160
|
+
* và điều hướng hiệu ứng chuyển động. Trạng thái `expanded` được truyền từ ngoài (controlled state),
|
|
161
|
+
* do đó bạn có thể quản lý qua react state, hoặc dùng router hay business logic khác.
|
|
162
|
+
*/
|
|
163
|
+
export interface FABMenuProps {
|
|
164
|
+
/** FAB Menu có đang mở (mở rộng)/hiển thị hay không. */
|
|
165
|
+
expanded: boolean;
|
|
166
|
+
/** Hàm handler kích hoạt khi Toggle FAB được người dùng tương tác, hoặc khi dismiss backdrop. */
|
|
167
|
+
onToggle: (expanded: boolean) => void;
|
|
168
|
+
/** Danh sách các action items (Spec MD3 đề nghị 2-6 item là hoàn hảo). */
|
|
169
|
+
items: FABMenuItemData[];
|
|
170
|
+
/**
|
|
171
|
+
* Vai trò màu (color role container) MD3 cho cái nút FAB lẫn các menu items.
|
|
172
|
+
* @default "primary"
|
|
173
|
+
*/
|
|
174
|
+
colorVariant?: "primary" | "secondary" | "tertiary";
|
|
175
|
+
/**
|
|
176
|
+
* Kích cỡ khởi tạo cho cái ToggleFAB (FAB biến hình thành cục đóng dấu X khi nó expanded).
|
|
177
|
+
* @default "baseline"
|
|
178
|
+
*/
|
|
179
|
+
fabSize?: "baseline" | "medium" | "large";
|
|
180
|
+
/**
|
|
181
|
+
* Căn lề của danh sách menu items tương quan với cái Toggle FAB.
|
|
182
|
+
* - `"end"`: Các items dồn hết theo lề phía tay phải (trailing edge, default cực hữu hiệu đối với RTL design).
|
|
183
|
+
* - `"start"`: Các items dồn hết dọc theo lề trái.
|
|
184
|
+
* - `"center"`: Các item sẽ được căn ra giữa chiều dọc, căn giữa tâm khối với cái FAB.
|
|
185
|
+
* @default "end"
|
|
186
|
+
*/
|
|
187
|
+
alignment?: "start" | "end" | "center";
|
|
188
|
+
/** Thuộc tính cho CSS component root để đè. */
|
|
189
|
+
className?: string;
|
|
190
|
+
/**
|
|
191
|
+
* Nếu `true`, khi menu đang hiện ra, click chuột ra phía sau (màn xám mờ backdrop) để đóng menu.
|
|
192
|
+
* @default true
|
|
193
|
+
*/
|
|
194
|
+
closeOnBackdropClick?: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Nếu `true`, focus sẽ tự động chạy xuống item CUỐI (sát bên trên cái nút FAB) khi menu vừa loé mờ ra.
|
|
197
|
+
* Nếu `false`, focus sẽ bay lên item ĐẦU TIÊN của danh sách (cao nhất trên màn hình).
|
|
198
|
+
* @default true
|
|
199
|
+
*/
|
|
200
|
+
focusLast?: boolean;
|
|
201
|
+
/** Bắt buộc truyền `aria-label` cho ToggleFAB để đáp ứng Accessibility. */
|
|
202
|
+
"aria-label"?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Props thuộc component `ToggleFAB` đứng độc lập.
|
|
207
|
+
*/
|
|
208
|
+
export interface ToggleFABProps {
|
|
209
|
+
/** Nút có đang trong trạng thái được kích hoạt check (expanded). */
|
|
210
|
+
expanded: boolean;
|
|
211
|
+
/** Gọi khi xảy ra sự kiện Toggle nút. */
|
|
212
|
+
onToggle: (expanded: boolean) => void;
|
|
213
|
+
/**
|
|
214
|
+
* Function sinh Icon - nhận về tiến độ `progress` trong khoảng `0` -> `1` (Từ Chưa Expanded -> Đã Expanded)
|
|
215
|
+
* Dùng cho các hiệu ứng morphing Icon khi animtion render (VD: Từ Cộng thành Đóng).
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```tsx
|
|
219
|
+
* icon={(progress) => progress > 0.5 ? <Icon name="close" /> : <Icon name="add" />}
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
icon: (progress: number) => React.ReactNode;
|
|
223
|
+
/** Vai trò màu container chuẩn MD3. @default "primary" */
|
|
224
|
+
colorVariant?: "primary" | "secondary" | "tertiary";
|
|
225
|
+
/** Kích thước của cục FAB ban đầu (Sau khi nhấn sẽ thu tròn). @default "baseline" */
|
|
226
|
+
fabSize?: "baseline" | "medium" | "large";
|
|
227
|
+
/** CSS Class linh tinh bổ sung thêm. */
|
|
228
|
+
className?: string;
|
|
229
|
+
/** Thuộc tính đọc thẻ accessibility. Bắt buộc có. */
|
|
230
|
+
"aria-label"?: string;
|
|
231
|
+
/** Kiểm soát giá trị của ID để link với menu qua aria-controls. */
|
|
232
|
+
"aria-controls"?: string;
|
|
233
|
+
/** Trỏ id component. */
|
|
234
|
+
id?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Props thuộc component một món item đơn lẻ `FABMenuItem`.
|
|
239
|
+
*/
|
|
240
|
+
export interface FABMenuItemProps {
|
|
241
|
+
/** Node của icon hiện. */
|
|
242
|
+
icon: React.ReactNode;
|
|
243
|
+
/** Tên nhãn mô tả kế bên icon cho item này. Hoặc ẩn nó đi nếu không mong đợi. */
|
|
244
|
+
label?: string;
|
|
245
|
+
/** Hàm bắn ra khi item được kích. */
|
|
246
|
+
onClick: () => void;
|
|
247
|
+
/** Vô hiệu hóa hành vi tương tác item mà vẫn cho phép bàn phím tab bấm dính lấy focus. @default false */
|
|
248
|
+
disabled?: boolean;
|
|
249
|
+
/** Container tông màu. @default "primary" */
|
|
250
|
+
colorVariant?: "primary" | "secondary" | "tertiary";
|
|
251
|
+
/** Custom CSS className. */
|
|
252
|
+
className?: string;
|
|
253
|
+
/** Số index liệt kê trong mảng dùng tính render delay (Dành cho animation cấu trúc `custom`). */
|
|
254
|
+
index?: number;
|
|
255
|
+
/** Tổng danh sách items có mảng. */
|
|
256
|
+
totalItems?: number;
|
|
257
|
+
/** Giá trị logic `tabIndex` dùng điều khiển thao tác phím Tab thủ công. */
|
|
258
|
+
tabIndex?: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
// Animation Variants
|
|
263
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
const ITEM_SPRING = { type: "spring" as const, stiffness: 700, damping: 25 };
|
|
266
|
+
|
|
267
|
+
const MENU_CONTAINER_VARIANTS = {
|
|
268
|
+
open: { transition: { staggerChildren: 0.033, staggerDirection: 1 } },
|
|
269
|
+
closed: { transition: { staggerChildren: 0.02, staggerDirection: -1 } },
|
|
270
|
+
} as const;
|
|
271
|
+
|
|
272
|
+
const MENU_ITEM_VARIANTS = {
|
|
273
|
+
open: { scaleX: 1, opacity: 1, transition: ITEM_SPRING },
|
|
274
|
+
closed: { scaleX: 0, opacity: 0, transition: ITEM_SPRING },
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Internal Icon Components
|
|
279
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function AddIcon() {
|
|
282
|
+
return (
|
|
283
|
+
<svg
|
|
284
|
+
aria-hidden="true"
|
|
285
|
+
viewBox="0 0 24 24"
|
|
286
|
+
fill="currentColor"
|
|
287
|
+
width="24"
|
|
288
|
+
height="24"
|
|
289
|
+
>
|
|
290
|
+
<title>Add</title>
|
|
291
|
+
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
|
292
|
+
</svg>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function CloseIcon() {
|
|
297
|
+
return (
|
|
298
|
+
<svg
|
|
299
|
+
aria-hidden="true"
|
|
300
|
+
viewBox="0 0 24 24"
|
|
301
|
+
fill="currentColor"
|
|
302
|
+
width="24"
|
|
303
|
+
height="24"
|
|
304
|
+
>
|
|
305
|
+
<title>Close</title>
|
|
306
|
+
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
307
|
+
</svg>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function defaultFabIcon(progress: number) {
|
|
312
|
+
return progress > 0.5 ? <CloseIcon /> : <AddIcon />;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
// ToggleFAB Component
|
|
317
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
const ToggleFABComponent = React.forwardRef<HTMLButtonElement, ToggleFABProps>(
|
|
320
|
+
(
|
|
321
|
+
{
|
|
322
|
+
expanded,
|
|
323
|
+
onToggle,
|
|
324
|
+
icon,
|
|
325
|
+
colorVariant = "primary",
|
|
326
|
+
fabSize = "baseline",
|
|
327
|
+
className,
|
|
328
|
+
id,
|
|
329
|
+
"aria-label": ariaLabel,
|
|
330
|
+
"aria-controls": ariaControls,
|
|
331
|
+
},
|
|
332
|
+
ref,
|
|
333
|
+
) => {
|
|
334
|
+
const prefersReduced = useReducedMotion();
|
|
335
|
+
const colors = TOGGLE_FAB_COLORS[colorVariant] ?? TOGGLE_FAB_COLORS.primary;
|
|
336
|
+
const sizeTokens = TOGGLE_FAB_SIZES[fabSize] ?? TOGGLE_FAB_SIZES.baseline;
|
|
337
|
+
|
|
338
|
+
const springConfig = prefersReduced ? SPRING_REDUCED : SPRING_NORMAL;
|
|
339
|
+
const checkedProgress = useSpring(expanded ? 1 : 0, springConfig);
|
|
340
|
+
|
|
341
|
+
React.useEffect(() => {
|
|
342
|
+
checkedProgress.set(expanded ? 1 : 0);
|
|
343
|
+
}, [expanded, checkedProgress]);
|
|
344
|
+
|
|
345
|
+
const borderRadius = useTransform(
|
|
346
|
+
checkedProgress,
|
|
347
|
+
[0, 1],
|
|
348
|
+
[`${sizeTokens.initialRadius}px`, `${sizeTokens.finalRadius}px`],
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const [iconProgress, setIconProgress] = React.useState(expanded ? 1 : 0);
|
|
352
|
+
|
|
353
|
+
React.useEffect(() => {
|
|
354
|
+
return checkedProgress.on("change", setIconProgress);
|
|
355
|
+
}, [checkedProgress]);
|
|
356
|
+
|
|
357
|
+
const { ripples, onPointerDown, removeRipple } = useRippleState();
|
|
358
|
+
|
|
359
|
+
const handleClick = React.useCallback(() => {
|
|
360
|
+
onToggle(!expanded);
|
|
361
|
+
}, [expanded, onToggle]);
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<m.button
|
|
365
|
+
ref={ref}
|
|
366
|
+
id={id}
|
|
367
|
+
type="button"
|
|
368
|
+
aria-expanded={expanded}
|
|
369
|
+
aria-haspopup="menu"
|
|
370
|
+
aria-label={ariaLabel ?? (expanded ? "Close menu" : "Open menu")}
|
|
371
|
+
aria-controls={ariaControls}
|
|
372
|
+
data-expanded={expanded ? "true" : "false"}
|
|
373
|
+
onClick={handleClick}
|
|
374
|
+
onPointerDown={onPointerDown}
|
|
375
|
+
style={{ borderRadius }}
|
|
376
|
+
animate={{
|
|
377
|
+
boxShadow: expanded
|
|
378
|
+
? "0 4px 8px 3px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3)"
|
|
379
|
+
: "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)",
|
|
380
|
+
}}
|
|
381
|
+
whileTap={{ scale: 0.95, transition: SPRING_TRANSITION_FAST }}
|
|
382
|
+
transition={{ boxShadow: SPRING_TRANSITION }}
|
|
383
|
+
className={cn(
|
|
384
|
+
"relative shrink-0 inline-flex items-center justify-center",
|
|
385
|
+
"select-none cursor-pointer overflow-hidden",
|
|
386
|
+
"transition-colors duration-200",
|
|
387
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2",
|
|
388
|
+
sizeTokens.sizeClass,
|
|
389
|
+
expanded ? colors.checkedBg : colors.containerBg,
|
|
390
|
+
expanded ? colors.checkedText : colors.containerText,
|
|
391
|
+
className,
|
|
392
|
+
)}
|
|
393
|
+
>
|
|
394
|
+
<TouchTarget />
|
|
395
|
+
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
396
|
+
<span
|
|
397
|
+
aria-hidden="true"
|
|
398
|
+
className="relative z-10 flex items-center justify-center size-6 pointer-events-none"
|
|
399
|
+
>
|
|
400
|
+
{icon(iconProgress)}
|
|
401
|
+
</span>
|
|
402
|
+
</m.button>
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
ToggleFABComponent.displayName = "ToggleFAB";
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Nút Toggle FAB (Biến hình) có thể được dùng độc lập đứng một mình (standalone) hoặc kết nối để kích hoạt mở cả nùi Menu ở dưới con `FABMenu`.
|
|
411
|
+
*
|
|
412
|
+
* Sức ép hiệu ứng kích thước khung nền sẽ chuyển từ vuôn/vát cạnh sang hình tròn hẵn (square → circle), chuyển biến cả màu sắc
|
|
413
|
+
* khi mà cờ `expanded` chuyển tiếp từ false sang → true.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```tsx
|
|
417
|
+
* const [open, setOpen] = React.useState(false);
|
|
418
|
+
* <ToggleFAB
|
|
419
|
+
* expanded={open}
|
|
420
|
+
* onToggle={setOpen}
|
|
421
|
+
* colorVariant="primary"
|
|
422
|
+
* aria-label="Toggle actions"
|
|
423
|
+
* icon={(progress) => progress > 0.5 ? <Icon name="close" /> : <Icon name="add" />}
|
|
424
|
+
* />
|
|
425
|
+
* ```
|
|
426
|
+
*/
|
|
427
|
+
export const ToggleFAB = React.memo(ToggleFABComponent);
|
|
428
|
+
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
// FABMenuItem Component
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Một item thực hiện một loại hành động duy nhất trong `FABMenu`.
|
|
435
|
+
*
|
|
436
|
+
* Render hình dạng viên thuốc bao quanh icon cùng với label diễn giải.
|
|
437
|
+
* Khi `label` bỏ trống, nó sẽ render thành một cục thẻ gạch ốp màu vuông vức chứa chữ mỗi cái icon.
|
|
438
|
+
* Component chứa gợn sóng MD3 Ripple cùng 48dp chuẩn vùng target đụng diện chuẩn WCAG 2.5.5 cho cảm ứng.
|
|
439
|
+
*
|
|
440
|
+
* @remarks
|
|
441
|
+
* Những thành phần khi bị tắt (vô hiệu tương tác) thì chỉ được dùng role `aria-disabled="true"` ở lớp div bề mặt thay vì lấy
|
|
442
|
+
* thuộc tính gốc `disabled` của HTML. Nhờ đó, item tuy xám mờ không bấm được phím chuột vẫn sẽ có khả năng focus qua vòng đời tab phím bàn phím
|
|
443
|
+
* (quy chuẩn chặt chẽ của Material Design 3).
|
|
444
|
+
*/
|
|
445
|
+
export function FABMenuItem({
|
|
446
|
+
icon,
|
|
447
|
+
label,
|
|
448
|
+
onClick,
|
|
449
|
+
disabled = false,
|
|
450
|
+
colorVariant = "primary",
|
|
451
|
+
className,
|
|
452
|
+
tabIndex = 0,
|
|
453
|
+
}: FABMenuItemProps) {
|
|
454
|
+
const colors = MENU_ITEM_COLORS[colorVariant] ?? MENU_ITEM_COLORS.primary;
|
|
455
|
+
|
|
456
|
+
const { ripples, onPointerDown, removeRipple } = useRippleState({ disabled });
|
|
457
|
+
|
|
458
|
+
const handleClick = React.useCallback(
|
|
459
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
460
|
+
if (disabled) {
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
onClick();
|
|
465
|
+
},
|
|
466
|
+
[disabled, onClick],
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const handleKeyDown = React.useCallback(
|
|
470
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
471
|
+
if (disabled) return;
|
|
472
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
onClick();
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
[disabled, onClick],
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<m.div
|
|
482
|
+
role="menuitem"
|
|
483
|
+
tabIndex={tabIndex}
|
|
484
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
485
|
+
data-disabled={disabled ? "true" : undefined}
|
|
486
|
+
onClick={handleClick}
|
|
487
|
+
onPointerDown={onPointerDown}
|
|
488
|
+
onKeyDown={handleKeyDown}
|
|
489
|
+
variants={MENU_ITEM_VARIANTS}
|
|
490
|
+
style={{
|
|
491
|
+
transformOrigin: "right",
|
|
492
|
+
borderRadius: `${MENU_ITEM_STYLES.cornerRadius}px`,
|
|
493
|
+
}}
|
|
494
|
+
className={cn(
|
|
495
|
+
"relative inline-flex flex-row items-center",
|
|
496
|
+
"select-none cursor-pointer overflow-hidden",
|
|
497
|
+
"whitespace-nowrap",
|
|
498
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-1",
|
|
499
|
+
MENU_ITEM_STYLES.size,
|
|
500
|
+
MENU_ITEM_STYLES.gap,
|
|
501
|
+
label ? MENU_ITEM_STYLES.padding : "px-4",
|
|
502
|
+
!label && "justify-center",
|
|
503
|
+
colors.bg,
|
|
504
|
+
colors.text,
|
|
505
|
+
disabled && "opacity-[0.38] pointer-events-none",
|
|
506
|
+
className,
|
|
507
|
+
)}
|
|
508
|
+
>
|
|
509
|
+
<TouchTarget />
|
|
510
|
+
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
511
|
+
<span
|
|
512
|
+
aria-hidden="true"
|
|
513
|
+
className="relative z-10 flex items-center justify-center size-6 shrink-0 [&>svg]:w-full [&>svg]:h-full pointer-events-none"
|
|
514
|
+
>
|
|
515
|
+
{icon}
|
|
516
|
+
</span>
|
|
517
|
+
{label && (
|
|
518
|
+
<span className="relative z-10 text-base font-medium leading-none pointer-events-none">
|
|
519
|
+
{label}
|
|
520
|
+
</span>
|
|
521
|
+
)}
|
|
522
|
+
</m.div>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
527
|
+
// FABMenu Component
|
|
528
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* MD3 Expressive FAB Menu.
|
|
532
|
+
*
|
|
533
|
+
* Bộ mở rộng Floating Action Button. Khi tương tác bật vào toggle-button (FAB con lai), sẽ trút ra mớ tác vụ menu ở xếp dọc (hoặc lan ra) từ trên đè ngược xuống trên nó.
|
|
534
|
+
*
|
|
535
|
+
* Accessibility thực thi chuẩn MD3 toàn phần:
|
|
536
|
+
* - thẻ `role="menu"` trang bị cho thẻ hộp div làm luống container
|
|
537
|
+
* - trang bị thẻ `role="menuitem"` trên mảng items thành phần con
|
|
538
|
+
* - Lifecycle Focus cho trải nghiệm hoàn mỹ: Mở phím bật -> Focus thẳng lên item cao/thấp đầu/cuối cùng menu; Đóng tắt menu -> Focus trả về ngược lại ToggleFAB
|
|
539
|
+
* - Tính năng Bàn Phím: Lách Escape nhấn tắt nhắm, Nhấn phím hướng lên-xuống(ArrowUp/Down) để di chuyển, Móc Tab(hoặc là Shift+Tab) nhảy lăng quăng qua các item.
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```tsx
|
|
543
|
+
* const [open, setOpen] = React.useState(false);
|
|
544
|
+
*
|
|
545
|
+
* const items = [
|
|
546
|
+
* { id: 'share', icon: <Icon name="share" />, label: 'Chia sẻ', onClick: () => console.log('Share') },
|
|
547
|
+
* { id: 'edit', icon: <Icon name="edit" />, label: 'Chỉnh sửa', onClick: () => console.log('Edit') },
|
|
548
|
+
* { id: 'delete', icon: <Icon name="delete" />, label: 'Xóa bớt', disabled: true, onClick: () => {} }
|
|
549
|
+
* ];
|
|
550
|
+
*
|
|
551
|
+
* <FABMenu
|
|
552
|
+
* expanded={open}
|
|
553
|
+
* onToggle={setOpen}
|
|
554
|
+
* aria-label="Các công cụ thao tác nhanh"
|
|
555
|
+
* alignment="center"
|
|
556
|
+
* colorVariant="tertiary"
|
|
557
|
+
* items={items}
|
|
558
|
+
* />
|
|
559
|
+
* ```
|
|
560
|
+
*
|
|
561
|
+
* @see https://m3.material.io/components/floating-action-button/overview
|
|
562
|
+
*/
|
|
563
|
+
export function FABMenu({
|
|
564
|
+
expanded,
|
|
565
|
+
onToggle,
|
|
566
|
+
items,
|
|
567
|
+
colorVariant = "primary",
|
|
568
|
+
fabSize = "baseline",
|
|
569
|
+
alignment = "end",
|
|
570
|
+
className,
|
|
571
|
+
closeOnBackdropClick = true,
|
|
572
|
+
focusLast = true,
|
|
573
|
+
"aria-label": ariaLabel,
|
|
574
|
+
}: FABMenuProps) {
|
|
575
|
+
const fabId = React.useId();
|
|
576
|
+
const menuId = React.useId();
|
|
577
|
+
const toggleRef = React.useRef<HTMLButtonElement>(null);
|
|
578
|
+
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
|
579
|
+
const [focusedIndex, setFocusedIndex] = React.useState<number>(-1);
|
|
580
|
+
|
|
581
|
+
const reversedItems = React.useMemo(() => [...items].reverse(), [items]);
|
|
582
|
+
|
|
583
|
+
const focusItem = React.useCallback((index: number) => {
|
|
584
|
+
const clampedIndex = Math.max(
|
|
585
|
+
0,
|
|
586
|
+
Math.min(index, itemRefs.current.length - 1),
|
|
587
|
+
);
|
|
588
|
+
setFocusedIndex(clampedIndex);
|
|
589
|
+
itemRefs.current[clampedIndex]?.focus();
|
|
590
|
+
}, []);
|
|
591
|
+
|
|
592
|
+
// Track whether menu was previously open so we only return focus
|
|
593
|
+
// to the toggle button after a user-initiated close, not on initial mount.
|
|
594
|
+
const wasExpandedRef = React.useRef(false);
|
|
595
|
+
|
|
596
|
+
React.useEffect(() => {
|
|
597
|
+
if (expanded) {
|
|
598
|
+
wasExpandedRef.current = true;
|
|
599
|
+
const timer = setTimeout(() => {
|
|
600
|
+
focusItem(focusLast ? items.length - 1 : 0);
|
|
601
|
+
}, FOCUS_DELAY_MS);
|
|
602
|
+
return () => clearTimeout(timer);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (wasExpandedRef.current) {
|
|
606
|
+
toggleRef.current?.focus();
|
|
607
|
+
}
|
|
608
|
+
wasExpandedRef.current = false;
|
|
609
|
+
setFocusedIndex(-1);
|
|
610
|
+
}, [expanded, focusLast, items.length, focusItem]);
|
|
611
|
+
|
|
612
|
+
const handleMenuKeyDown = React.useCallback(
|
|
613
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
614
|
+
if (!expanded) return;
|
|
615
|
+
|
|
616
|
+
const lastIndex = items.length - 1;
|
|
617
|
+
|
|
618
|
+
switch (e.key) {
|
|
619
|
+
case "Escape":
|
|
620
|
+
e.preventDefault();
|
|
621
|
+
onToggle(false);
|
|
622
|
+
break;
|
|
623
|
+
|
|
624
|
+
case "ArrowUp": {
|
|
625
|
+
e.preventDefault();
|
|
626
|
+
focusItem(focusedIndex <= 0 ? lastIndex : focusedIndex - 1);
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
case "ArrowDown": {
|
|
631
|
+
e.preventDefault();
|
|
632
|
+
focusItem(focusedIndex >= lastIndex ? 0 : focusedIndex + 1);
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
case "Tab": {
|
|
637
|
+
e.preventDefault();
|
|
638
|
+
if (e.shiftKey) {
|
|
639
|
+
focusItem(focusedIndex <= 0 ? lastIndex : focusedIndex - 1);
|
|
640
|
+
} else {
|
|
641
|
+
focusItem(focusedIndex >= lastIndex ? 0 : focusedIndex + 1);
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
[expanded, focusedIndex, items.length, focusItem, onToggle],
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<LazyMotion features={domMax} strict>
|
|
652
|
+
{expanded && closeOnBackdropClick && (
|
|
653
|
+
<div
|
|
654
|
+
aria-hidden="true"
|
|
655
|
+
className="fixed inset-0 z-40"
|
|
656
|
+
onClick={() => onToggle(false)}
|
|
657
|
+
/>
|
|
658
|
+
)}
|
|
659
|
+
|
|
660
|
+
{/* biome-ignore lint/a11y/useSemanticElements: FAB menu container needs div, not fieldset — this is not a form group */}
|
|
661
|
+
<div
|
|
662
|
+
role="group"
|
|
663
|
+
aria-label={ariaLabel ?? "Actions menu"}
|
|
664
|
+
className={cn(
|
|
665
|
+
"fixed z-50 flex flex-col gap-2",
|
|
666
|
+
ALIGNMENT_CONTAINER_CLASSES[alignment],
|
|
667
|
+
"*:shrink-0",
|
|
668
|
+
className,
|
|
669
|
+
)}
|
|
670
|
+
onKeyDown={handleMenuKeyDown}
|
|
671
|
+
>
|
|
672
|
+
<AnimatePresence>
|
|
673
|
+
{expanded && (
|
|
674
|
+
<m.div
|
|
675
|
+
id={menuId}
|
|
676
|
+
role="menu"
|
|
677
|
+
aria-labelledby={fabId}
|
|
678
|
+
aria-orientation="vertical"
|
|
679
|
+
variants={MENU_CONTAINER_VARIANTS}
|
|
680
|
+
initial="closed"
|
|
681
|
+
animate="open"
|
|
682
|
+
exit="closed"
|
|
683
|
+
className={cn(
|
|
684
|
+
"flex flex-col-reverse gap-2",
|
|
685
|
+
ALIGNMENT_ITEMS_CLASSES[alignment],
|
|
686
|
+
)}
|
|
687
|
+
>
|
|
688
|
+
{reversedItems.map((item, reversedIndex) => {
|
|
689
|
+
const originalIndex = items.length - 1 - reversedIndex;
|
|
690
|
+
return (
|
|
691
|
+
<m.div
|
|
692
|
+
key={item.id}
|
|
693
|
+
variants={MENU_ITEM_VARIANTS}
|
|
694
|
+
style={{
|
|
695
|
+
transformOrigin:
|
|
696
|
+
ALIGNMENT_TRANSFORM_ORIGIN[alignment] ?? "right",
|
|
697
|
+
}}
|
|
698
|
+
ref={(el) => {
|
|
699
|
+
itemRefs.current[originalIndex] = el;
|
|
700
|
+
}}
|
|
701
|
+
>
|
|
702
|
+
<FABMenuItem
|
|
703
|
+
icon={item.icon}
|
|
704
|
+
label={item.label}
|
|
705
|
+
onClick={() => {
|
|
706
|
+
if (!item.disabled) {
|
|
707
|
+
item.onClick();
|
|
708
|
+
onToggle(false);
|
|
709
|
+
}
|
|
710
|
+
}}
|
|
711
|
+
disabled={item.disabled}
|
|
712
|
+
colorVariant={colorVariant}
|
|
713
|
+
className={item.className}
|
|
714
|
+
tabIndex={expanded ? 0 : -1}
|
|
715
|
+
/>
|
|
716
|
+
</m.div>
|
|
717
|
+
);
|
|
718
|
+
})}
|
|
719
|
+
</m.div>
|
|
720
|
+
)}
|
|
721
|
+
</AnimatePresence>
|
|
722
|
+
|
|
723
|
+
<ToggleFAB
|
|
724
|
+
ref={toggleRef}
|
|
725
|
+
id={fabId}
|
|
726
|
+
expanded={expanded}
|
|
727
|
+
onToggle={onToggle}
|
|
728
|
+
colorVariant={colorVariant}
|
|
729
|
+
fabSize={fabSize}
|
|
730
|
+
aria-label={
|
|
731
|
+
ariaLabel ?? (expanded ? "Close actions menu" : "Open actions menu")
|
|
732
|
+
}
|
|
733
|
+
aria-controls={menuId}
|
|
734
|
+
icon={defaultFabIcon}
|
|
735
|
+
/>
|
|
736
|
+
</div>
|
|
737
|
+
</LazyMotion>
|
|
738
|
+
);
|
|
739
|
+
}
|