@bug-on/md3-react 2.0.3 → 3.0.1
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 +42 -0
- package/CHANGELOG.md +69 -0
- package/dist/index.css +178 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6135 -0
- package/dist/index.d.ts +6135 -71
- package/dist/index.js +1688 -631
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1600 -564
- 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/plugin.d.mts +1 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +13 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugin.mjs +3 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +28 -19
- package/scripts/copy-assets.js +115 -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 +195 -0
- package/src/lib/utils.ts +6 -0
- package/src/plugin.ts +12 -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 +306 -0
- package/src/ui/button.tsx +665 -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 +607 -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 +135 -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 +215 -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,234 @@
|
|
|
1
|
+
import { AnimatePresence, m, useReducedMotion } from "motion/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// MD3 Expressive Ripple
|
|
6
|
+
//
|
|
7
|
+
// - Origin: pointer-down coordinates (x, y) relative to container
|
|
8
|
+
// - Shape: perfectly round circle, expands from origin to diagonally fill btn
|
|
9
|
+
// - Color: currentColor at 12% opacity (matches MD3 state layer spec)
|
|
10
|
+
// - A11y: disabled when `prefers-reduced-motion` is active (configurable)
|
|
11
|
+
// - Clipping: caller wraps in overflow-hidden; border-radius handled on parent
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Represents a single ripple wave instance with position and size metadata.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const ripple: RippleOrigin = { id: Date.now(), x: 50, y: 30, size: 200 };
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export interface RippleOrigin {
|
|
23
|
+
/** Unique identifier used as React key and for removal. */
|
|
24
|
+
id: number;
|
|
25
|
+
/** X coordinate of the pointer event relative to the container's left edge (px). */
|
|
26
|
+
x: number;
|
|
27
|
+
/** Y coordinate of the pointer event relative to the container's top edge (px). */
|
|
28
|
+
y: number;
|
|
29
|
+
/**
|
|
30
|
+
* Diameter of the ripple circle (px).
|
|
31
|
+
* Typically `Math.hypot(width, height) * 2` to ensure it fills the container.
|
|
32
|
+
*/
|
|
33
|
+
size: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @internal Props for a single animated ripple element. */
|
|
37
|
+
interface RippleItemProps {
|
|
38
|
+
ripple: RippleOrigin;
|
|
39
|
+
onDone: (id: number) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @internal Memoized single ripple wave — minimises re-renders. */
|
|
43
|
+
const RippleItem = React.memo(function RippleItem({
|
|
44
|
+
ripple,
|
|
45
|
+
onDone,
|
|
46
|
+
}: RippleItemProps) {
|
|
47
|
+
return (
|
|
48
|
+
<m.span
|
|
49
|
+
key={ripple.id}
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
style={{
|
|
52
|
+
position: "absolute",
|
|
53
|
+
left: ripple.x - ripple.size / 2,
|
|
54
|
+
top: ripple.y - ripple.size / 2,
|
|
55
|
+
width: ripple.size,
|
|
56
|
+
height: ripple.size,
|
|
57
|
+
borderRadius: "50%",
|
|
58
|
+
backgroundColor: "currentColor",
|
|
59
|
+
pointerEvents: "none",
|
|
60
|
+
transformOrigin: "center",
|
|
61
|
+
}}
|
|
62
|
+
initial={{ scale: 0, opacity: 0.12 }}
|
|
63
|
+
animate={{ scale: 1, opacity: 0 }}
|
|
64
|
+
exit={{ opacity: 0 }}
|
|
65
|
+
transition={{
|
|
66
|
+
scale: { duration: 0.5, ease: [0.2, 0, 0, 1] },
|
|
67
|
+
opacity: { duration: 0.4, ease: "easeOut", delay: 0.1 },
|
|
68
|
+
}}
|
|
69
|
+
onAnimationComplete={() => onDone(ripple.id)}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// Ripple Component
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Props for the `Ripple` presentation component.
|
|
80
|
+
*/
|
|
81
|
+
export interface RippleProps {
|
|
82
|
+
/** Active ripple instances to render. Managed by the parent via `useRipple`. */
|
|
83
|
+
ripples: RippleOrigin[];
|
|
84
|
+
/** Called when a ripple's exit animation completes — remove it from state. */
|
|
85
|
+
onRippleDone: (id: number) => void;
|
|
86
|
+
/**
|
|
87
|
+
* Completely disables the ripple effect.
|
|
88
|
+
* Use this when the parent element is disabled or interaction is not desired.
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* When `true`, the ripple respects the user's OS-level
|
|
94
|
+
* `prefers-reduced-motion` accessibility setting and renders nothing if active.
|
|
95
|
+
*
|
|
96
|
+
* Set to `false` to always show ripples regardless of system preference.
|
|
97
|
+
* @default true
|
|
98
|
+
*/
|
|
99
|
+
respectSystemMotion?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* MD3 Expressive Ripple — animated touch-feedback wave layer.
|
|
104
|
+
*
|
|
105
|
+
* Renders absolutely-positioned ripple circles inside an `overflow-hidden`
|
|
106
|
+
* container. Must be placed as a direct child of the interactive element.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* - The parent element **must** have `overflow: hidden` and `position: relative`
|
|
110
|
+
* (or equivalent) for clipping to work correctly.
|
|
111
|
+
* - Set `disabled` to `true` on parent's disabled state to avoid stale ripples.
|
|
112
|
+
* - The ripple color is `currentColor` at 12% opacity — matching MD3 state layer spec.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```tsx
|
|
116
|
+
* const { ripples, onPointerDown, removeRipple } = useRippleState();
|
|
117
|
+
*
|
|
118
|
+
* <button onPointerDown={onPointerDown} className="relative overflow-hidden">
|
|
119
|
+
* <Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
120
|
+
* Click me
|
|
121
|
+
* </button>
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @see {@link useRippleState} for the state management hook
|
|
125
|
+
* @see https://m3.material.io/foundations/interaction/states/overview
|
|
126
|
+
*/
|
|
127
|
+
export function Ripple({
|
|
128
|
+
ripples,
|
|
129
|
+
onRippleDone,
|
|
130
|
+
disabled = false,
|
|
131
|
+
respectSystemMotion = true,
|
|
132
|
+
}: RippleProps) {
|
|
133
|
+
const prefersReduced = useReducedMotion();
|
|
134
|
+
|
|
135
|
+
// Disabled prop: explicitly turned off by consumer
|
|
136
|
+
if (disabled) return null;
|
|
137
|
+
|
|
138
|
+
// Respect system prefers-reduced-motion when opted-in
|
|
139
|
+
if (respectSystemMotion && prefersReduced) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<AnimatePresence>
|
|
143
|
+
{ripples.map((r) => (
|
|
144
|
+
<RippleItem key={r.id} ripple={r} onDone={onRippleDone} />
|
|
145
|
+
))}
|
|
146
|
+
</AnimatePresence>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
// useRippleState Hook
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Options for configuring `useRippleState` behaviour.
|
|
156
|
+
*/
|
|
157
|
+
export interface UseRippleStateOptions {
|
|
158
|
+
/**
|
|
159
|
+
* When `true`, the ripple is suppressed — `onPointerDown` becomes a no-op.
|
|
160
|
+
* Use this to sync the ripple with the parent element's `disabled` state.
|
|
161
|
+
* @default false
|
|
162
|
+
*/
|
|
163
|
+
disabled?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `useRippleState` — state manager for MD3 Expressive ripple waves.
|
|
168
|
+
*
|
|
169
|
+
* Tracks active ripple instances and provides pointer event handlers.
|
|
170
|
+
* Pair with the `<Ripple>` component for rendering.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* This hook only manages ripple *state* (coordinates, size, lifecycle).
|
|
174
|
+
* The actual animation is handled by `<Ripple>` via Framer Motion.
|
|
175
|
+
* Respecting `prefers-reduced-motion` is handled by `<Ripple>` itself.
|
|
176
|
+
*
|
|
177
|
+
* @param options - Configuration options. See {@link UseRippleStateOptions}.
|
|
178
|
+
* @returns `{ ripples, onPointerDown, removeRipple }` — bind to the interactive element.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```tsx
|
|
182
|
+
* function MyButton({ disabled, children }) {
|
|
183
|
+
* const { ripples, onPointerDown, removeRipple } = useRippleState({ disabled });
|
|
184
|
+
*
|
|
185
|
+
* return (
|
|
186
|
+
* <button
|
|
187
|
+
* disabled={disabled}
|
|
188
|
+
* onPointerDown={onPointerDown}
|
|
189
|
+
* className="relative overflow-hidden"
|
|
190
|
+
* >
|
|
191
|
+
* <Ripple ripples={ripples} onRippleDone={removeRipple} disabled={disabled} />
|
|
192
|
+
* {children}
|
|
193
|
+
* </button>
|
|
194
|
+
* );
|
|
195
|
+
* }
|
|
196
|
+
* ```
|
|
197
|
+
*
|
|
198
|
+
* @see {@link Ripple} for the rendering component
|
|
199
|
+
*/
|
|
200
|
+
export function useRippleState(options: UseRippleStateOptions = {}) {
|
|
201
|
+
const { disabled = false } = options;
|
|
202
|
+
const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
|
|
203
|
+
|
|
204
|
+
const onPointerDown = React.useCallback(
|
|
205
|
+
(e: React.PointerEvent<HTMLElement>) => {
|
|
206
|
+
if (disabled) return;
|
|
207
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
208
|
+
const x = e.clientX - rect.left;
|
|
209
|
+
const y = e.clientY - rect.top;
|
|
210
|
+
const rippleSize = Math.hypot(rect.width, rect.height) * 2;
|
|
211
|
+
setRipples((prev) => [
|
|
212
|
+
...prev,
|
|
213
|
+
{ id: Date.now(), x, y, size: rippleSize },
|
|
214
|
+
]);
|
|
215
|
+
},
|
|
216
|
+
[disabled],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const removeRipple = React.useCallback((id: number) => {
|
|
220
|
+
setRipples((prev) => prev.filter((r) => r.id !== id));
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
return { ripples, onPointerDown, removeRipple };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// Legacy alias — preserved for backward-compatibility
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @deprecated Use `useRippleState` instead. This alias will be removed in a future version.
|
|
232
|
+
* @see {@link useRippleState}
|
|
233
|
+
*/
|
|
234
|
+
export const useRipple = useRippleState;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ScrollArea } from "./scroll-area";
|
|
4
|
+
|
|
5
|
+
describe("ScrollArea", () => {
|
|
6
|
+
it("renders correctly with children", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ScrollArea className="h-40 w-40">
|
|
9
|
+
<div data-testid="content">Long content here</div>
|
|
10
|
+
</ScrollArea>,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(screen.getByTestId("content")).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders scrollbars when type is 'always'", async () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<ScrollArea type="always" className="h-40 w-40">
|
|
19
|
+
<div style={{ height: "1000px" }}>Overflowing content</div>
|
|
20
|
+
</ScrollArea>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
await waitFor(() => {
|
|
24
|
+
const scrollbars = container.querySelectorAll("[data-orientation]");
|
|
25
|
+
expect(scrollbars.length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("respects orientation prop", async () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<ScrollArea type="always" orientation="horizontal" className="h-40 w-40">
|
|
32
|
+
<div style={{ width: "1000px" }}>Overflowing content</div>
|
|
33
|
+
</ScrollArea>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
const scrollbar = container.querySelector(
|
|
38
|
+
'[data-orientation="horizontal"]',
|
|
39
|
+
);
|
|
40
|
+
expect(scrollbar).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("hides scrollbar when type is 'none'", async () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<ScrollArea type="none" className="h-40 w-40">
|
|
47
|
+
<div style={{ height: "1000px" }}>Overflowing content</div>
|
|
48
|
+
</ScrollArea>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await waitFor(() => {
|
|
52
|
+
const scrollbar = container.querySelector(
|
|
53
|
+
'[data-orientation="vertical"]',
|
|
54
|
+
);
|
|
55
|
+
expect(scrollbar).toHaveClass("hidden");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as RadixScrollArea from "@radix-ui/react-scroll-area";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
/** Radix accepts hover/scroll/always/auto. We add 'none' as a UI-only hide. */
|
|
9
|
+
export type ScrollAreaType = "hover" | "scroll" | "always" | "none";
|
|
10
|
+
type RadixScrollAreaType = "hover" | "scroll" | "always" | "auto";
|
|
11
|
+
export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
|
|
12
|
+
|
|
13
|
+
export interface ScrollAreaProps
|
|
14
|
+
extends Omit<
|
|
15
|
+
React.ComponentPropsWithoutRef<typeof RadixScrollArea.Root>,
|
|
16
|
+
"type"
|
|
17
|
+
> {
|
|
18
|
+
/**
|
|
19
|
+
* Controls when the scrollbars are visible.
|
|
20
|
+
* - `hover`: Show on hover (default, recommended for desktop)
|
|
21
|
+
* - `scroll`: Show only while scrolling (recommended for mobile)
|
|
22
|
+
* - `always`: Always visible
|
|
23
|
+
* - `none`: Never visible
|
|
24
|
+
*/
|
|
25
|
+
type?: ScrollAreaType;
|
|
26
|
+
/**
|
|
27
|
+
* The scrollbar orientation to render.
|
|
28
|
+
* @default "vertical"
|
|
29
|
+
*/
|
|
30
|
+
orientation?: ScrollAreaOrientation;
|
|
31
|
+
/** Delay in ms before scrollbars hide when `type` is `hover` or `scroll`. */
|
|
32
|
+
scrollHideDelay?: number;
|
|
33
|
+
/** Extra classes applied to the inner viewport element. */
|
|
34
|
+
viewportClassName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
38
|
+
const ScrollArea = React.forwardRef<
|
|
39
|
+
React.ElementRef<typeof RadixScrollArea.Root>,
|
|
40
|
+
ScrollAreaProps
|
|
41
|
+
>(
|
|
42
|
+
(
|
|
43
|
+
{
|
|
44
|
+
className,
|
|
45
|
+
viewportClassName,
|
|
46
|
+
children,
|
|
47
|
+
type = "hover",
|
|
48
|
+
orientation = "vertical",
|
|
49
|
+
scrollHideDelay = 600,
|
|
50
|
+
...props
|
|
51
|
+
},
|
|
52
|
+
ref,
|
|
53
|
+
) => {
|
|
54
|
+
const radixType: RadixScrollAreaType = type === "none" ? "always" : type;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<RadixScrollArea.Root
|
|
58
|
+
ref={ref}
|
|
59
|
+
type={radixType}
|
|
60
|
+
scrollHideDelay={scrollHideDelay}
|
|
61
|
+
className={cn("relative overflow-hidden flex flex-col", className)}
|
|
62
|
+
{...props}
|
|
63
|
+
>
|
|
64
|
+
<RadixScrollArea.Viewport
|
|
65
|
+
className={cn(
|
|
66
|
+
"h-full w-full flex-1 min-h-0 min-w-0 rounded-[inherit]",
|
|
67
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-1",
|
|
68
|
+
viewportClassName,
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</RadixScrollArea.Viewport>
|
|
73
|
+
|
|
74
|
+
{(orientation === "vertical" || orientation === "both") && (
|
|
75
|
+
<ScrollAreaScrollbar
|
|
76
|
+
orientation="vertical"
|
|
77
|
+
className={type === "none" ? "hidden" : undefined}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{(orientation === "horizontal" || orientation === "both") && (
|
|
82
|
+
<ScrollAreaScrollbar
|
|
83
|
+
orientation="horizontal"
|
|
84
|
+
className={type === "none" ? "hidden" : undefined}
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<RadixScrollArea.Corner className="bg-m3-surface-container" />
|
|
89
|
+
</RadixScrollArea.Root>
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
ScrollArea.displayName = "ScrollArea";
|
|
94
|
+
|
|
95
|
+
// ─── Scrollbar ────────────────────────────────────────────────────────────────
|
|
96
|
+
const ScrollAreaScrollbar = React.forwardRef<
|
|
97
|
+
React.ElementRef<typeof RadixScrollArea.Scrollbar>,
|
|
98
|
+
React.ComponentPropsWithoutRef<typeof RadixScrollArea.Scrollbar>
|
|
99
|
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
100
|
+
<RadixScrollArea.Scrollbar
|
|
101
|
+
ref={ref}
|
|
102
|
+
orientation={orientation}
|
|
103
|
+
className={cn(
|
|
104
|
+
"flex touch-none select-none transition-all duration-300 ease-in-out",
|
|
105
|
+
"absolute z-50",
|
|
106
|
+
orientation === "vertical" &&
|
|
107
|
+
"right-0 top-0 bottom-0 w-2.5 border-l border-l-transparent p-px",
|
|
108
|
+
orientation === "horizontal" &&
|
|
109
|
+
"bottom-0 left-0 right-0 h-2.5 flex-col border-t border-t-transparent p-px",
|
|
110
|
+
"data-[state=hidden]:opacity-0 data-[state=visible]:opacity-100",
|
|
111
|
+
className,
|
|
112
|
+
)}
|
|
113
|
+
{...props}
|
|
114
|
+
>
|
|
115
|
+
<RadixScrollArea.Thumb
|
|
116
|
+
className={cn(
|
|
117
|
+
"relative flex-1 rounded-full bg-m3-on-surface/25 transition-colors duration-200",
|
|
118
|
+
"hover:bg-m3-on-surface/40 active:bg-m3-on-surface/55",
|
|
119
|
+
"before:absolute before:left-1/2 before:top-1/2 before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
|
|
120
|
+
)}
|
|
121
|
+
/>
|
|
122
|
+
</RadixScrollArea.Scrollbar>
|
|
123
|
+
));
|
|
124
|
+
ScrollAreaScrollbar.displayName = RadixScrollArea.Scrollbar.displayName;
|
|
125
|
+
|
|
126
|
+
// ─── Corner ───────────────────────────────────────────────────────────────────
|
|
127
|
+
const ScrollAreaCorner = React.forwardRef<
|
|
128
|
+
React.ComponentRef<typeof RadixScrollArea.Corner>,
|
|
129
|
+
React.ComponentPropsWithoutRef<typeof RadixScrollArea.Corner>
|
|
130
|
+
>(({ className, ...props }, ref) => (
|
|
131
|
+
<RadixScrollArea.Corner
|
|
132
|
+
ref={ref}
|
|
133
|
+
className={cn("bg-m3-surface-container", className)}
|
|
134
|
+
{...props}
|
|
135
|
+
/>
|
|
136
|
+
));
|
|
137
|
+
ScrollAreaCorner.displayName = "ScrollAreaCorner";
|
|
138
|
+
|
|
139
|
+
export { ScrollArea, ScrollAreaScrollbar };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file animated-placeholder.tsx
|
|
3
|
+
* MD3 Expressive Search — Animated placeholder overlay.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the native `::placeholder` with a GPU-accelerated `translateX`
|
|
6
|
+
* animation so the placeholder text can smoothly slide from its alignment
|
|
7
|
+
* position (center / right) to left when the search input is focused.
|
|
8
|
+
*
|
|
9
|
+
* Implementation notes:
|
|
10
|
+
* - Only `transform: translateX` is animated → no layout triggers, no paint.
|
|
11
|
+
* - Container width is measured once via `useLayoutEffect` (before paint) to
|
|
12
|
+
* avoid a first-render flash, then kept fresh via `ResizeObserver`.
|
|
13
|
+
* - xOffset is stored in `useState` so Framer Motion picks up changes and
|
|
14
|
+
* re-animates smoothly on container resize.
|
|
15
|
+
* - The span is never unmounted — only opacity-toggled — to preserve the
|
|
16
|
+
* measurement ref between renders.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { m, useReducedMotion } from "motion/react";
|
|
20
|
+
import * as React from "react";
|
|
21
|
+
import { cn } from "../../lib/utils";
|
|
22
|
+
import { SEARCH_COLORS, SEARCH_TYPOGRAPHY } from "./search.tokens";
|
|
23
|
+
|
|
24
|
+
/** Spring tuned to match MD3 Standard Decelerate curve. */
|
|
25
|
+
const PLACEHOLDER_SPRING = {
|
|
26
|
+
type: "spring" as const,
|
|
27
|
+
stiffness: 350,
|
|
28
|
+
damping: 30,
|
|
29
|
+
mass: 0.8,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface AnimatedPlaceholderProps {
|
|
33
|
+
/** Placeholder text to display. */
|
|
34
|
+
text: string;
|
|
35
|
+
/** Alignment of the placeholder when idle (not focused). @default "left" */
|
|
36
|
+
textAlign: "left" | "center" | "right";
|
|
37
|
+
/**
|
|
38
|
+
* Whether the placeholder should be visible.
|
|
39
|
+
* Pass `!query` — hide when the user has typed something.
|
|
40
|
+
*/
|
|
41
|
+
visible: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the search is in an active/focused state.
|
|
44
|
+
* When `true`, the placeholder snaps to `left` regardless of `textAlign`.
|
|
45
|
+
*/
|
|
46
|
+
focused: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The `<input>` element that this component wraps.
|
|
49
|
+
* It should have `w-full` instead of `flex-1` since this wrapper
|
|
50
|
+
* takes over the `flex-1` role in the parent flex layout.
|
|
51
|
+
*/
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
/** Extra className forwarded to the wrapper div. */
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wraps a search `<input>` with an animated placeholder overlay.
|
|
59
|
+
*
|
|
60
|
+
* The wrapper div occupies `flex-1` so it fits seamlessly in the
|
|
61
|
+
* horizontal flex layout used by search bar headers. The children
|
|
62
|
+
* (the `<input>`) should use `w-full` to fill the wrapper.
|
|
63
|
+
*
|
|
64
|
+
* Accessibility: `aria-label` on the `<input>` carries the placeholder
|
|
65
|
+
* text for screen readers; this span is `aria-hidden="true"`.
|
|
66
|
+
*/
|
|
67
|
+
export function AnimatedPlaceholder({
|
|
68
|
+
text,
|
|
69
|
+
textAlign,
|
|
70
|
+
visible,
|
|
71
|
+
focused,
|
|
72
|
+
children,
|
|
73
|
+
className,
|
|
74
|
+
}: AnimatedPlaceholderProps) {
|
|
75
|
+
const shouldReduceMotion = useReducedMotion();
|
|
76
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
77
|
+
const spanRef = React.useRef<HTMLSpanElement | null>(null);
|
|
78
|
+
|
|
79
|
+
// Offset in pixels from left when idle. 0 means no animation (left-aligned).
|
|
80
|
+
const [xOffset, setXOffset] = React.useState(0);
|
|
81
|
+
|
|
82
|
+
const recalculate = React.useCallback(() => {
|
|
83
|
+
const container = containerRef.current;
|
|
84
|
+
const span = spanRef.current;
|
|
85
|
+
if (!container || !span || textAlign === "left") {
|
|
86
|
+
setXOffset(0);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const containerWidth = container.offsetWidth;
|
|
90
|
+
const textWidth = span.offsetWidth;
|
|
91
|
+
if (textAlign === "center") {
|
|
92
|
+
setXOffset(Math.max(0, (containerWidth - textWidth) / 2));
|
|
93
|
+
} else {
|
|
94
|
+
// right
|
|
95
|
+
setXOffset(Math.max(0, containerWidth - textWidth));
|
|
96
|
+
}
|
|
97
|
+
}, [textAlign]);
|
|
98
|
+
|
|
99
|
+
// Measure synchronously before first paint to prevent a position flash.
|
|
100
|
+
React.useLayoutEffect(() => {
|
|
101
|
+
recalculate();
|
|
102
|
+
}, [recalculate]);
|
|
103
|
+
|
|
104
|
+
// Keep measurement fresh when the container is resized.
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
const container = containerRef.current;
|
|
107
|
+
if (!container) return;
|
|
108
|
+
const observer = new ResizeObserver(recalculate);
|
|
109
|
+
observer.observe(container);
|
|
110
|
+
return () => observer.disconnect();
|
|
111
|
+
}, [recalculate]);
|
|
112
|
+
|
|
113
|
+
// When focused or no longer visible (user typed), snap to left (x=0).
|
|
114
|
+
const targetX = focused || !visible ? 0 : xOffset;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
ref={containerRef}
|
|
119
|
+
className={cn("relative flex-1 min-w-0", className)}
|
|
120
|
+
>
|
|
121
|
+
{children}
|
|
122
|
+
|
|
123
|
+
{/* Animated placeholder — decorative only, aria-hidden */}
|
|
124
|
+
<m.span
|
|
125
|
+
ref={(el) => {
|
|
126
|
+
spanRef.current = el;
|
|
127
|
+
}}
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
className={cn(
|
|
130
|
+
"pointer-events-none absolute inset-y-0 left-0",
|
|
131
|
+
"flex items-center whitespace-nowrap select-none",
|
|
132
|
+
SEARCH_TYPOGRAPHY.bodyLarge,
|
|
133
|
+
)}
|
|
134
|
+
style={{ color: SEARCH_COLORS.supportingText }}
|
|
135
|
+
animate={{
|
|
136
|
+
x: targetX,
|
|
137
|
+
opacity: visible ? 1 : 0,
|
|
138
|
+
}}
|
|
139
|
+
transition={shouldReduceMotion ? { duration: 0 } : PLACEHOLDER_SPRING}
|
|
140
|
+
>
|
|
141
|
+
{text}
|
|
142
|
+
</m.span>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|