@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,289 @@
|
|
|
1
|
+
// ─── MD3 Expressive Menu — Root (Menu, MenuTrigger, MenuContent) ─────────────
|
|
2
|
+
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
3
|
+
import { AnimatePresence, m } from "motion/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { MENU_CONTAINER_VARIANTS } from "./menu-animations";
|
|
7
|
+
import { MenuProvider, useMenuContext } from "./menu-context";
|
|
8
|
+
import {
|
|
9
|
+
BASELINE_COLORS,
|
|
10
|
+
MENU_CONTAINER_SHAPE,
|
|
11
|
+
MENU_GROUP_GAP,
|
|
12
|
+
MENU_MAX_WIDTH,
|
|
13
|
+
MENU_MIN_WIDTH,
|
|
14
|
+
MENU_POPUP_PADDING_Y,
|
|
15
|
+
STANDARD_COLORS,
|
|
16
|
+
VIBRANT_COLORS,
|
|
17
|
+
} from "./menu-tokens";
|
|
18
|
+
import type {
|
|
19
|
+
MenuContentProps,
|
|
20
|
+
MenuGroupProps,
|
|
21
|
+
MenuProps,
|
|
22
|
+
MenuTriggerProps,
|
|
23
|
+
} from "./menu-types";
|
|
24
|
+
|
|
25
|
+
// ─── Menu (Root) ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* MD3 Expressive Menu root component.
|
|
29
|
+
*
|
|
30
|
+
* Wraps Radix `DropdownMenu.Root` and provides `MenuContext` with `colorVariant`
|
|
31
|
+
* and `open` state to all descendant MenuItem and MenuGroup components.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <Menu colorVariant="standard">
|
|
35
|
+
* <MenuTrigger asChild>
|
|
36
|
+
* <IconButton name="more_vert" />
|
|
37
|
+
* </MenuTrigger>
|
|
38
|
+
* <MenuContent>
|
|
39
|
+
* <MenuGroup index={0} count={2}>
|
|
40
|
+
* <MenuItem>Cut</MenuItem>
|
|
41
|
+
* <MenuItem>Copy</MenuItem>
|
|
42
|
+
* </MenuGroup>
|
|
43
|
+
* <MenuGroup index={1} count={2}>
|
|
44
|
+
* <MenuItem>Paste</MenuItem>
|
|
45
|
+
* </MenuGroup>
|
|
46
|
+
* </MenuContent>
|
|
47
|
+
* </Menu>
|
|
48
|
+
*/
|
|
49
|
+
export function Menu({
|
|
50
|
+
children,
|
|
51
|
+
variant,
|
|
52
|
+
menuVariant,
|
|
53
|
+
colorVariant = "standard",
|
|
54
|
+
open: controlledOpen,
|
|
55
|
+
onOpenChange: controlledOnOpenChange,
|
|
56
|
+
defaultOpen,
|
|
57
|
+
...props
|
|
58
|
+
}: MenuProps &
|
|
59
|
+
Omit<React.ComponentPropsWithoutRef<typeof DropdownMenu.Root>, "children">) {
|
|
60
|
+
// Support deprecated menuVariant prop
|
|
61
|
+
const resolvedVariant = variant ?? menuVariant ?? "baseline";
|
|
62
|
+
|
|
63
|
+
// Support both controlled and uncontrolled open state.
|
|
64
|
+
// Initialize internalOpen from defaultOpen so that `defaultOpen={true}` works.
|
|
65
|
+
const [internalOpen, setInternalOpen] = React.useState(
|
|
66
|
+
() => defaultOpen ?? false,
|
|
67
|
+
);
|
|
68
|
+
const isControlled = controlledOpen !== undefined;
|
|
69
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
70
|
+
|
|
71
|
+
const handleOpenChange = React.useCallback(
|
|
72
|
+
(next: boolean) => {
|
|
73
|
+
if (!isControlled) setInternalOpen(next);
|
|
74
|
+
controlledOnOpenChange?.(next);
|
|
75
|
+
},
|
|
76
|
+
[isControlled, controlledOnOpenChange],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<MenuProvider
|
|
81
|
+
variant={resolvedVariant}
|
|
82
|
+
colorVariant={colorVariant}
|
|
83
|
+
open={open}
|
|
84
|
+
onOpenChange={handleOpenChange}
|
|
85
|
+
>
|
|
86
|
+
<DropdownMenu.Root
|
|
87
|
+
{...props}
|
|
88
|
+
defaultOpen={defaultOpen}
|
|
89
|
+
open={isControlled ? open : undefined}
|
|
90
|
+
onOpenChange={handleOpenChange}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</DropdownMenu.Root>
|
|
94
|
+
</MenuProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
Menu.displayName = "Menu";
|
|
98
|
+
|
|
99
|
+
// ─── MenuTrigger ──────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The trigger element that opens/closes the Menu.
|
|
103
|
+
*
|
|
104
|
+
* Use `asChild` to merge trigger behavior with your own element (e.g. a Button or IconButton).
|
|
105
|
+
*/
|
|
106
|
+
export const MenuTrigger = React.forwardRef<
|
|
107
|
+
React.ComponentRef<typeof DropdownMenu.Trigger>,
|
|
108
|
+
MenuTriggerProps & React.ComponentPropsWithoutRef<typeof DropdownMenu.Trigger>
|
|
109
|
+
>(({ children, asChild = true, ...props }, ref) => (
|
|
110
|
+
<DropdownMenu.Trigger ref={ref} asChild={asChild} {...props}>
|
|
111
|
+
{children}
|
|
112
|
+
</DropdownMenu.Trigger>
|
|
113
|
+
));
|
|
114
|
+
MenuTrigger.displayName = "MenuTrigger";
|
|
115
|
+
|
|
116
|
+
// ─── MenuContent (popup panel) ────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The popup container for the menu's contents.
|
|
120
|
+
*
|
|
121
|
+
* Renders into a portal. Uses Radix `forceMount` + Framer Motion `AnimatePresence`
|
|
122
|
+
* so the exit animation (scale + opacity via FastEffects) plays before the portal
|
|
123
|
+
* unmounts. The `open` state is read from `MenuContext`.
|
|
124
|
+
*
|
|
125
|
+
* Transform-origin is automatically set via the Radix CSS variable
|
|
126
|
+
* `--radix-dropdown-menu-content-transform-origin`.
|
|
127
|
+
*
|
|
128
|
+
* @param hasOverflow - Set true when using SubMenu to prevent clipping
|
|
129
|
+
*/
|
|
130
|
+
export const MenuContent = React.forwardRef<
|
|
131
|
+
React.ComponentRef<typeof DropdownMenu.Content>,
|
|
132
|
+
MenuContentProps &
|
|
133
|
+
Omit<React.ComponentPropsWithoutRef<typeof DropdownMenu.Content>, "asChild">
|
|
134
|
+
>(
|
|
135
|
+
(
|
|
136
|
+
{
|
|
137
|
+
children,
|
|
138
|
+
sideOffset = 6,
|
|
139
|
+
side = "bottom",
|
|
140
|
+
align = "start",
|
|
141
|
+
hasOverflow = false,
|
|
142
|
+
colorVariant: propColorVariant,
|
|
143
|
+
separatorStyle = "gap",
|
|
144
|
+
className,
|
|
145
|
+
...props
|
|
146
|
+
},
|
|
147
|
+
ref,
|
|
148
|
+
) => {
|
|
149
|
+
const {
|
|
150
|
+
open,
|
|
151
|
+
variant,
|
|
152
|
+
colorVariant: contextColorVariant,
|
|
153
|
+
} = useMenuContext();
|
|
154
|
+
const colorVariant = propColorVariant ?? contextColorVariant;
|
|
155
|
+
|
|
156
|
+
// Baseline always uses baseline colors; expressive uses colorVariant
|
|
157
|
+
const colors =
|
|
158
|
+
variant === "baseline"
|
|
159
|
+
? BASELINE_COLORS
|
|
160
|
+
: colorVariant === "vibrant"
|
|
161
|
+
? VIBRANT_COLORS
|
|
162
|
+
: STANDARD_COLORS;
|
|
163
|
+
|
|
164
|
+
const isExpressiveGap =
|
|
165
|
+
variant === "expressive" && separatorStyle === "gap";
|
|
166
|
+
|
|
167
|
+
// Expressive variant: large rounded container with elevation (unless gap variant)
|
|
168
|
+
// Baseline variant: CornerExtraSmall (4px) container
|
|
169
|
+
const containerClassName =
|
|
170
|
+
variant === "expressive"
|
|
171
|
+
? cn(
|
|
172
|
+
"z-50 flex flex-col",
|
|
173
|
+
MENU_MIN_WIDTH,
|
|
174
|
+
MENU_MAX_WIDTH,
|
|
175
|
+
isExpressiveGap ? MENU_GROUP_GAP : "",
|
|
176
|
+
isExpressiveGap ? "bg-transparent" : colors.containerBg,
|
|
177
|
+
isExpressiveGap ? "" : "rounded-2xl",
|
|
178
|
+
isExpressiveGap ? "" : "elevation-2",
|
|
179
|
+
hasOverflow || isExpressiveGap
|
|
180
|
+
? "overflow-visible"
|
|
181
|
+
: "overflow-hidden",
|
|
182
|
+
"outline-none",
|
|
183
|
+
className,
|
|
184
|
+
)
|
|
185
|
+
: cn(
|
|
186
|
+
"z-50 flex flex-col",
|
|
187
|
+
MENU_MIN_WIDTH,
|
|
188
|
+
MENU_MAX_WIDTH,
|
|
189
|
+
MENU_POPUP_PADDING_Y,
|
|
190
|
+
MENU_GROUP_GAP,
|
|
191
|
+
colors.containerBg,
|
|
192
|
+
MENU_CONTAINER_SHAPE,
|
|
193
|
+
"elevation-2",
|
|
194
|
+
hasOverflow ? "overflow-visible" : "overflow-hidden",
|
|
195
|
+
"outline-none",
|
|
196
|
+
className,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Helper to recursively flatten fragments
|
|
200
|
+
const flattenChildren = (nodes: React.ReactNode): React.ReactElement[] => {
|
|
201
|
+
return React.Children.toArray(nodes).reduce(
|
|
202
|
+
(acc: React.ReactElement[], child) => {
|
|
203
|
+
if (React.isValidElement(child)) {
|
|
204
|
+
if (child.type === React.Fragment) {
|
|
205
|
+
return acc.concat(
|
|
206
|
+
flattenChildren(
|
|
207
|
+
(child as React.ReactElement<{ children?: React.ReactNode }>)
|
|
208
|
+
.props.children,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
acc.push(child as React.ReactElement);
|
|
213
|
+
}
|
|
214
|
+
return acc;
|
|
215
|
+
},
|
|
216
|
+
[],
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
let renderedChildren: React.ReactNode = children;
|
|
221
|
+
|
|
222
|
+
if (variant === "expressive") {
|
|
223
|
+
const validChildren = flattenChildren(children);
|
|
224
|
+
const groupCount = validChildren.length;
|
|
225
|
+
|
|
226
|
+
const enhancedChildren = validChildren.map((child, i) =>
|
|
227
|
+
React.cloneElement(child as React.ReactElement<MenuGroupProps>, {
|
|
228
|
+
index: i,
|
|
229
|
+
count: groupCount,
|
|
230
|
+
isGapVariant: isExpressiveGap,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
renderedChildren =
|
|
235
|
+
separatorStyle === "divider"
|
|
236
|
+
? enhancedChildren.reduce<React.ReactNode[]>((acc, child, i) => {
|
|
237
|
+
if (i > 0) {
|
|
238
|
+
acc.push(
|
|
239
|
+
<hr
|
|
240
|
+
key={`divider-${(child as React.ReactElement).key || i}`}
|
|
241
|
+
className={cn(
|
|
242
|
+
"mx-3 my-0.5 h-px border-0 bg-m3-outline-variant",
|
|
243
|
+
)}
|
|
244
|
+
/>,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
acc.push(child);
|
|
248
|
+
return acc;
|
|
249
|
+
}, [])
|
|
250
|
+
: enhancedChildren;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<AnimatePresence>
|
|
255
|
+
{open && (
|
|
256
|
+
<DropdownMenu.Portal forceMount>
|
|
257
|
+
<DropdownMenu.Content
|
|
258
|
+
ref={ref}
|
|
259
|
+
sideOffset={sideOffset}
|
|
260
|
+
side={side}
|
|
261
|
+
align={align}
|
|
262
|
+
asChild
|
|
263
|
+
forceMount
|
|
264
|
+
{...props}
|
|
265
|
+
>
|
|
266
|
+
<m.div
|
|
267
|
+
role="menu"
|
|
268
|
+
aria-orientation="vertical"
|
|
269
|
+
className={containerClassName}
|
|
270
|
+
variants={MENU_CONTAINER_VARIANTS}
|
|
271
|
+
initial="hidden"
|
|
272
|
+
animate="visible"
|
|
273
|
+
exit="exit"
|
|
274
|
+
style={{
|
|
275
|
+
...(props.style as React.CSSProperties),
|
|
276
|
+
transformOrigin:
|
|
277
|
+
"var(--radix-dropdown-menu-content-transform-origin)",
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{renderedChildren}
|
|
281
|
+
</m.div>
|
|
282
|
+
</DropdownMenu.Content>
|
|
283
|
+
</DropdownMenu.Portal>
|
|
284
|
+
)}
|
|
285
|
+
</AnimatePresence>
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
MenuContent.displayName = "MenuContent";
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// ─── MD3 Expressive Menu — SubMenu ───────────────────────────────────────────
|
|
2
|
+
// Nested sub-menu triggered by hover/keyboard on a MenuItem
|
|
3
|
+
import * as ContextMenu from "@radix-ui/react-context-menu";
|
|
4
|
+
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
5
|
+
import { AnimatePresence, m } from "motion/react";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { cn } from "../../lib/utils";
|
|
8
|
+
import { Icon } from "../icon";
|
|
9
|
+
import { SUBMENU_CONTAINER_VARIANTS } from "./menu-animations";
|
|
10
|
+
import { useMenuContext } from "./menu-context";
|
|
11
|
+
import {
|
|
12
|
+
BASELINE_COLORS,
|
|
13
|
+
MENU_CONTAINER_SHAPE,
|
|
14
|
+
MENU_GROUP_GAP,
|
|
15
|
+
MENU_MAX_WIDTH,
|
|
16
|
+
MENU_MIN_WIDTH,
|
|
17
|
+
MENU_POPUP_PADDING_Y,
|
|
18
|
+
STANDARD_COLORS,
|
|
19
|
+
VIBRANT_COLORS,
|
|
20
|
+
} from "./menu-tokens";
|
|
21
|
+
import type {
|
|
22
|
+
MenuColorVariant,
|
|
23
|
+
MenuItemProps,
|
|
24
|
+
SubMenuProps,
|
|
25
|
+
} from "./menu-types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A nested SubMenu that opens from a trigger MenuItem.
|
|
29
|
+
*
|
|
30
|
+
* Keyboard: ArrowRight opens, ArrowLeft/Escape closes (handled by Radix).
|
|
31
|
+
* The parent MenuContent should set `hasOverflow={true}` when SubMenus are used.
|
|
32
|
+
*
|
|
33
|
+
* ### Hover delays
|
|
34
|
+
* `hoverOpenDelay` (default: 200ms) — time before the submenu opens on hover.
|
|
35
|
+
* `hoverCloseDelay` (default: 300ms) — time before the submenu closes after pointer-leave.
|
|
36
|
+
* These delays allow the user to safely move diagonally from trigger to submenu content
|
|
37
|
+
* without accidental close (safe polygon behavior from Radix still applies).
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* <MenuContent hasOverflow>
|
|
41
|
+
* <SubMenu
|
|
42
|
+
* trigger={
|
|
43
|
+
* <MenuItem trailingIcon={<Icon name="chevron_right" size={20} />}>
|
|
44
|
+
* Share
|
|
45
|
+
* </MenuItem>
|
|
46
|
+
* }
|
|
47
|
+
* >
|
|
48
|
+
* <MenuItem>Via Email</MenuItem>
|
|
49
|
+
* <MenuItem>Via Link</MenuItem>
|
|
50
|
+
* </SubMenu>
|
|
51
|
+
* </MenuContent>
|
|
52
|
+
*/
|
|
53
|
+
export function SubMenu({
|
|
54
|
+
children,
|
|
55
|
+
trigger,
|
|
56
|
+
side = "right",
|
|
57
|
+
colorVariant: propColorVariant,
|
|
58
|
+
hoverOpenDelay = 200,
|
|
59
|
+
hoverCloseDelay = 300,
|
|
60
|
+
}: SubMenuProps) {
|
|
61
|
+
const { colorVariant: contextColorVariant, menuPrimitive } = useMenuContext();
|
|
62
|
+
const colorVariant = propColorVariant ?? contextColorVariant;
|
|
63
|
+
|
|
64
|
+
// Controlled open state for hover delay support.
|
|
65
|
+
// Note: We use Radix's controlled mode carefully — Radix still handles keyboard
|
|
66
|
+
// and the safe polygon logic internally when we pass open/onOpenChange.
|
|
67
|
+
const [open, setOpen] = React.useState(false);
|
|
68
|
+
const openTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
69
|
+
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
70
|
+
null,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const clearTimers = React.useCallback(() => {
|
|
74
|
+
if (openTimerRef.current) clearTimeout(openTimerRef.current);
|
|
75
|
+
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleTriggerPointerEnter = React.useCallback(() => {
|
|
79
|
+
clearTimers();
|
|
80
|
+
openTimerRef.current = setTimeout(() => setOpen(true), hoverOpenDelay);
|
|
81
|
+
}, [hoverOpenDelay, clearTimers]);
|
|
82
|
+
|
|
83
|
+
const handleTriggerPointerLeave = React.useCallback(() => {
|
|
84
|
+
clearTimers();
|
|
85
|
+
closeTimerRef.current = setTimeout(() => setOpen(false), hoverCloseDelay);
|
|
86
|
+
}, [hoverCloseDelay, clearTimers]);
|
|
87
|
+
|
|
88
|
+
const handleContentPointerEnter = React.useCallback(() => {
|
|
89
|
+
// Keep open when pointer moves into the submenu content
|
|
90
|
+
clearTimers();
|
|
91
|
+
}, [clearTimers]);
|
|
92
|
+
|
|
93
|
+
const handleContentPointerLeave = React.useCallback(() => {
|
|
94
|
+
clearTimers();
|
|
95
|
+
closeTimerRef.current = setTimeout(() => setOpen(false), hoverCloseDelay);
|
|
96
|
+
}, [hoverCloseDelay, clearTimers]);
|
|
97
|
+
|
|
98
|
+
// Cleanup timers on unmount
|
|
99
|
+
React.useEffect(() => () => clearTimers(), [clearTimers]);
|
|
100
|
+
|
|
101
|
+
// Select the correct Radix Sub primitives based on which menu family is active.
|
|
102
|
+
const Sub = menuPrimitive === "context" ? ContextMenu.Sub : DropdownMenu.Sub;
|
|
103
|
+
const SubTrigger =
|
|
104
|
+
menuPrimitive === "context"
|
|
105
|
+
? ContextMenu.SubTrigger
|
|
106
|
+
: DropdownMenu.SubTrigger;
|
|
107
|
+
const SubContent =
|
|
108
|
+
menuPrimitive === "context"
|
|
109
|
+
? ContextMenu.SubContent
|
|
110
|
+
: DropdownMenu.SubContent;
|
|
111
|
+
const Portal =
|
|
112
|
+
menuPrimitive === "context" ? ContextMenu.Portal : DropdownMenu.Portal;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Sub open={open} onOpenChange={setOpen}>
|
|
116
|
+
{/* SubTrigger renders its own element (no asChild) so it can correctly
|
|
117
|
+
* compute the bounding box for SubContent positioning. */}
|
|
118
|
+
<SubTrigger
|
|
119
|
+
className="w-full outline-none"
|
|
120
|
+
onPointerEnter={handleTriggerPointerEnter}
|
|
121
|
+
onPointerLeave={handleTriggerPointerLeave}
|
|
122
|
+
>
|
|
123
|
+
{React.isValidElement(trigger)
|
|
124
|
+
? React.cloneElement(trigger as React.ReactElement<MenuItemProps>, {
|
|
125
|
+
isSubTrigger: true,
|
|
126
|
+
// Auto-add chevron if missing
|
|
127
|
+
trailingIcon: (trigger.props as MenuItemProps).trailingIcon || (
|
|
128
|
+
<Icon name="chevron_right" size={20} />
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
: trigger}
|
|
132
|
+
</SubTrigger>
|
|
133
|
+
|
|
134
|
+
{/* SubMenu popup */}
|
|
135
|
+
<AnimatePresence>
|
|
136
|
+
{open && (
|
|
137
|
+
<Portal forceMount>
|
|
138
|
+
<SubContent
|
|
139
|
+
sideOffset={4}
|
|
140
|
+
alignOffset={-4}
|
|
141
|
+
forceMount
|
|
142
|
+
className="outline-none"
|
|
143
|
+
>
|
|
144
|
+
<SubMenuContent
|
|
145
|
+
side={side}
|
|
146
|
+
colorVariant={colorVariant}
|
|
147
|
+
onPointerEnter={handleContentPointerEnter}
|
|
148
|
+
onPointerLeave={handleContentPointerLeave}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</SubMenuContent>
|
|
152
|
+
</SubContent>
|
|
153
|
+
</Portal>
|
|
154
|
+
)}
|
|
155
|
+
</AnimatePresence>
|
|
156
|
+
</Sub>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
SubMenu.displayName = "SubMenu";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Inner wrapper to handle animations.
|
|
163
|
+
*/
|
|
164
|
+
function SubMenuContent({
|
|
165
|
+
children,
|
|
166
|
+
side,
|
|
167
|
+
colorVariant: propColorVariant,
|
|
168
|
+
onPointerEnter,
|
|
169
|
+
onPointerLeave,
|
|
170
|
+
}: {
|
|
171
|
+
children: React.ReactNode;
|
|
172
|
+
side: "left" | "right";
|
|
173
|
+
colorVariant?: MenuColorVariant;
|
|
174
|
+
onPointerEnter?: React.PointerEventHandler<HTMLDivElement>;
|
|
175
|
+
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
|
176
|
+
}) {
|
|
177
|
+
const { menuVariant, colorVariant: contextColorVariant } = useMenuContext();
|
|
178
|
+
const colorVariant = propColorVariant ?? contextColorVariant;
|
|
179
|
+
const colors =
|
|
180
|
+
menuVariant === "baseline"
|
|
181
|
+
? BASELINE_COLORS
|
|
182
|
+
: colorVariant === "vibrant"
|
|
183
|
+
? VIBRANT_COLORS
|
|
184
|
+
: STANDARD_COLORS;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<m.div
|
|
188
|
+
role="menu"
|
|
189
|
+
aria-orientation="vertical"
|
|
190
|
+
onPointerEnter={onPointerEnter}
|
|
191
|
+
onPointerLeave={onPointerLeave}
|
|
192
|
+
className={cn(
|
|
193
|
+
"z-50 flex flex-col",
|
|
194
|
+
// Width constraints
|
|
195
|
+
MENU_MIN_WIDTH,
|
|
196
|
+
MENU_MAX_WIDTH,
|
|
197
|
+
// Vertical padding: 8dp
|
|
198
|
+
MENU_POPUP_PADDING_Y,
|
|
199
|
+
// Gap between groups: 2dp
|
|
200
|
+
MENU_GROUP_GAP,
|
|
201
|
+
// Container background
|
|
202
|
+
colors.containerBg,
|
|
203
|
+
// Container shape: CornerExtraSmall (4px)
|
|
204
|
+
MENU_CONTAINER_SHAPE,
|
|
205
|
+
// Elevation-2 shadow
|
|
206
|
+
"elevation-2",
|
|
207
|
+
// Overflow clip
|
|
208
|
+
"overflow-hidden",
|
|
209
|
+
"outline-none",
|
|
210
|
+
)}
|
|
211
|
+
variants={SUBMENU_CONTAINER_VARIANTS}
|
|
212
|
+
initial="hidden"
|
|
213
|
+
animate="visible"
|
|
214
|
+
exit="exit"
|
|
215
|
+
style={{
|
|
216
|
+
transformOrigin: side === "right" ? "top left" : "top right",
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{children}
|
|
220
|
+
</m.div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
SubMenuContent.displayName = "SubMenuContent";
|