@bug-on/md3-react 3.0.1 → 3.0.3
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 -42
- package/CHANGELOG.md +10 -0
- package/dist/index.css +107 -0
- package/dist/index.d.mts +1491 -1053
- package/dist/index.d.ts +1491 -1053
- package/dist/index.js +4457 -3156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4394 -3109
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -6
- package/scripts/copy-assets.js +113 -8
- package/src/index.ts +66 -18
- package/src/test/button.test.tsx +1 -1
- package/src/ui/app-bar/app-bar.tokens.ts +5 -24
- package/src/ui/badge.tsx +2 -1
- package/src/ui/buttons/button/button-tokens.ts +118 -0
- package/src/ui/{button.test.tsx → buttons/button/button.test.tsx} +0 -21
- package/src/ui/buttons/button/button.tsx +381 -0
- package/src/ui/buttons/button/index.ts +3 -0
- package/src/ui/buttons/button/types.ts +90 -0
- package/src/ui/buttons/button-group/button-group-defaults.ts +95 -0
- package/src/ui/buttons/button-group/button-group-tokens.ts +20 -0
- package/src/ui/{button-group.test.tsx → buttons/button-group/button-group.test.tsx} +9 -10
- package/src/ui/buttons/button-group/button-group.tsx +699 -0
- package/src/ui/buttons/button-group/index.ts +8 -0
- package/src/ui/buttons/button-group/types.ts +77 -0
- package/src/ui/{fab.tsx → buttons/fabs/fab/fab.tsx} +6 -6
- package/src/ui/buttons/fabs/fab/index.ts +1 -0
- package/src/ui/{fab-menu.tsx → buttons/fabs/fab-menu/fab-menu.tsx} +7 -4
- package/src/ui/buttons/fabs/fab-menu/index.ts +1 -0
- package/src/ui/buttons/fabs/index.ts +2 -0
- package/src/ui/{icon-button.tsx → buttons/icon-button/icon-button.tsx} +6 -6
- package/src/ui/buttons/icon-button/index.ts +1 -0
- package/src/ui/buttons/index.ts +4 -0
- package/src/ui/code-block.tsx +1 -1
- package/src/ui/dialog.tsx +4 -7
- package/src/ui/drawer.tsx +4 -7
- package/src/ui/menu/menu-animations.ts +14 -20
- package/src/ui/menu/menu-tokens.ts +7 -5
- package/src/ui/menu/menu.test.tsx +9 -4
- package/src/ui/navigation-bar.test.tsx +111 -0
- package/src/ui/navigation-bar.tsx +464 -0
- package/src/ui/navigation-rail.test.tsx +5 -4
- package/src/ui/navigation-rail.tsx +32 -23
- package/src/ui/scroll-area.tsx +4 -0
- package/src/ui/search/search-view-fullscreen.tsx +1 -1
- package/src/ui/search/search.tokens.ts +9 -43
- package/src/ui/search/trailing-action.tsx +1 -1
- package/src/ui/shared/constants.ts +25 -27
- package/src/ui/shared/motion-tokens.ts +238 -0
- package/src/ui/snackbar/snackbar.tsx +4 -6
- package/src/ui/switch/switch.tsx +12 -18
- package/src/ui/text-field/text-field.tokens.ts +12 -12
- package/src/ui/text-field/text-field.tsx +31 -19
- package/src/ui/theme-provider/index.tsx +1 -5
- package/src/ui/toc.tsx +1 -1
- package/src/ui/toolbar/__snapshots__/bottom-docked-toolbar.test.tsx.snap +51 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar-with-fab.test.tsx.snap +113 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar.test.tsx.snap +169 -0
- package/src/ui/toolbar/bottom-docked-toolbar.test.tsx +114 -0
- package/src/ui/toolbar/docked-toolbar.tsx +186 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.test.tsx +139 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.tsx +199 -0
- package/src/ui/toolbar/floating-toolbar.test.tsx +230 -0
- package/src/ui/toolbar/floating-toolbar.tsx +344 -0
- package/src/ui/toolbar/index.ts +35 -0
- package/src/ui/toolbar/toolbar-colors.ts +37 -0
- package/src/ui/toolbar/toolbar-context.tsx +13 -0
- package/src/ui/toolbar/toolbar-divider.test.tsx +54 -0
- package/src/ui/toolbar/toolbar-divider.tsx +73 -0
- package/src/ui/toolbar/toolbar-icon-button.test.tsx +68 -0
- package/src/ui/toolbar/toolbar-icon-button.tsx +136 -0
- package/src/ui/toolbar/toolbar-scroll-behavior.ts +140 -0
- package/src/ui/toolbar/toolbar-tokens.ts +51 -0
- package/test-clip.html +31 -0
- package/test-shadow.html +5 -1
- package/test-width.html +34 -0
- package/src/ui/button-group.tsx +0 -350
- package/src/ui/button.tsx +0 -665
- package/test-render.tsx +0 -4
- package/test_output.txt +0 -164
- package/test_output_v2.txt +0 -5
- /package/src/ui/{fab.test.tsx → buttons/fabs/fab/fab.test.tsx} +0 -0
- /package/src/ui/{fab-menu.test.tsx → buttons/fabs/fab-menu/fab-menu.test.tsx} +0 -0
- /package/src/ui/{icon-button.test.tsx → buttons/icon-button/icon-button.test.tsx} +0 -0
- /package/src/ui/{Text.tsx → text.tsx} +0 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import {
|
|
5
|
+
AnimatePresence,
|
|
6
|
+
domMax,
|
|
7
|
+
LazyMotion,
|
|
8
|
+
m,
|
|
9
|
+
type Transition,
|
|
10
|
+
} from "motion/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { cn } from "../lib/utils";
|
|
13
|
+
import { Icon } from "./icon";
|
|
14
|
+
import { Ripple, useRippleState } from "./ripple";
|
|
15
|
+
import { SPRING_TRANSITION_EXPRESSIVE } from "./shared/constants";
|
|
16
|
+
import { TouchTarget } from "./shared/touch-target";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Types
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Layout styling for navigation bar items.
|
|
24
|
+
* - vertical: Icon above label (default for mobile)
|
|
25
|
+
* - horizontal: Icon beside label (forced)
|
|
26
|
+
*/
|
|
27
|
+
export type NavigationBarItemLayout = "vertical" | "horizontal";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Visual variant of the Navigation Bar.
|
|
31
|
+
* - flexible: Default MD3 behavior (h-16), becomes horizontal on desktop.
|
|
32
|
+
* - baseline: Taller MD3 behavior (h-20), always vertical.
|
|
33
|
+
* - xr: Floating orbiter variant for spatial interfaces (detached from bottom).
|
|
34
|
+
*/
|
|
35
|
+
export type NavigationBarVariant = "flexible" | "baseline" | "xr";
|
|
36
|
+
|
|
37
|
+
export interface NavigationBarItemProps {
|
|
38
|
+
selected: boolean;
|
|
39
|
+
icon: React.ReactNode;
|
|
40
|
+
label: React.ReactNode;
|
|
41
|
+
onClick?: () => void;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
badge?: React.ReactNode;
|
|
44
|
+
"aria-label"?: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NavigationBarProps {
|
|
49
|
+
/** Visual variant of the Navigation Bar */
|
|
50
|
+
variant?: NavigationBarVariant;
|
|
51
|
+
/** Forces a specific item layout (horizontal/vertical) */
|
|
52
|
+
itemLayout?: NavigationBarItemLayout;
|
|
53
|
+
/** Whether the bar should hide when scrolling down */
|
|
54
|
+
hideOnScroll?: boolean;
|
|
55
|
+
/** Whether the bar should have an elevation shadow */
|
|
56
|
+
elevated?: boolean;
|
|
57
|
+
/** Whether the bar is fixed to the viewport (default) or absolute */
|
|
58
|
+
fixed?: boolean;
|
|
59
|
+
/** Container ref to track scrolling for hideOnScroll */
|
|
60
|
+
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
|
61
|
+
/** Transition for the active indicator pill */
|
|
62
|
+
activeIndicatorTransition?: Transition;
|
|
63
|
+
/** Navigation items */
|
|
64
|
+
children: React.ReactNode;
|
|
65
|
+
/** Optional additional classes */
|
|
66
|
+
className?: string;
|
|
67
|
+
/** Optional inline styles */
|
|
68
|
+
style?: React.CSSProperties;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Context
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const NavigationBarContext = React.createContext<{
|
|
76
|
+
variant: NavigationBarVariant;
|
|
77
|
+
itemLayout?: NavigationBarItemLayout;
|
|
78
|
+
activeIndicatorTransition?: Transition;
|
|
79
|
+
}>({ variant: "flexible" });
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Helpers
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function cloneIconWithFill(
|
|
86
|
+
icon: React.ReactNode,
|
|
87
|
+
selected: boolean,
|
|
88
|
+
): React.ReactNode {
|
|
89
|
+
if (!React.isValidElement(icon)) return icon;
|
|
90
|
+
if ((icon.type as unknown) === Icon) {
|
|
91
|
+
return React.cloneElement(
|
|
92
|
+
icon as React.ReactElement<{ fill?: 0 | 1; animateFill?: boolean }>,
|
|
93
|
+
{ fill: selected ? 1 : 0, animateFill: true },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return icon;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// NavigationBarItem Sub-components
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function ActivePill() {
|
|
104
|
+
const { activeIndicatorTransition } = React.useContext(NavigationBarContext);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<m.div
|
|
108
|
+
className="absolute inset-0 bg-m3-secondary-container pointer-events-none"
|
|
109
|
+
style={{
|
|
110
|
+
borderRadius: 9999,
|
|
111
|
+
zIndex: 0,
|
|
112
|
+
}}
|
|
113
|
+
initial={{ opacity: 0, scale: 0.5 }}
|
|
114
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
115
|
+
exit={{ opacity: 0, scale: 0.5 }}
|
|
116
|
+
transition={activeIndicatorTransition || SPRING_TRANSITION_EXPRESSIVE}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function HoverStateLayer() {
|
|
122
|
+
return (
|
|
123
|
+
<div className="absolute inset-0 rounded-full bg-m3-on-surface opacity-0 group-hover:opacity-[0.08] group-focus-visible:opacity-[0.10] active:opacity-[0.10] transition-opacity duration-200 pointer-events-none z-0" />
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RippleLayerProps {
|
|
128
|
+
ripples: ReturnType<typeof useRippleState>["ripples"];
|
|
129
|
+
onRippleDone: ReturnType<typeof useRippleState>["removeRipple"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function RippleLayer({ ripples, onRippleDone }: RippleLayerProps) {
|
|
133
|
+
return (
|
|
134
|
+
<div className="absolute inset-0 rounded-full overflow-hidden pointer-events-none z-0">
|
|
135
|
+
<Ripple ripples={ripples} onRippleDone={onRippleDone} />
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface IconContainerProps {
|
|
141
|
+
selected: boolean;
|
|
142
|
+
badge?: React.ReactNode;
|
|
143
|
+
children: React.ReactNode;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function IconContainer({ selected, badge, children }: IconContainerProps) {
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
className={cn(
|
|
151
|
+
"relative flex items-center justify-center size-6 transition-colors duration-200 shrink-0",
|
|
152
|
+
selected
|
|
153
|
+
? "text-m3-on-secondary-container"
|
|
154
|
+
: "text-m3-on-surface-variant",
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
{badge && (
|
|
159
|
+
<span className="absolute -top-1 -right-1 flex min-w-3 h-3 items-center justify-center rounded-full bg-m3-error px-1 text-[10px] font-medium leading-none tracking-normal text-m3-on-error ring-[1.5px] ring-m3-surface">
|
|
160
|
+
{badge}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
// NavigationBarItem
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const NavigationBarItemComponent = React.forwardRef<
|
|
172
|
+
HTMLButtonElement,
|
|
173
|
+
NavigationBarItemProps
|
|
174
|
+
>(
|
|
175
|
+
(
|
|
176
|
+
{
|
|
177
|
+
selected,
|
|
178
|
+
icon,
|
|
179
|
+
label,
|
|
180
|
+
onClick,
|
|
181
|
+
disabled = false,
|
|
182
|
+
badge,
|
|
183
|
+
className,
|
|
184
|
+
"aria-label": ariaLabelProp,
|
|
185
|
+
},
|
|
186
|
+
ref,
|
|
187
|
+
) => {
|
|
188
|
+
const { variant, itemLayout } = React.useContext(NavigationBarContext);
|
|
189
|
+
|
|
190
|
+
const isForcedHorizontal = itemLayout === "horizontal";
|
|
191
|
+
const isResponsiveHorizontal =
|
|
192
|
+
(variant === "flexible" || variant === "xr") && itemLayout === undefined;
|
|
193
|
+
|
|
194
|
+
const { ripples, onPointerDown, removeRipple } = useRippleState({
|
|
195
|
+
disabled,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const handleClick = React.useCallback(
|
|
199
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
200
|
+
if (disabled) {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (selected) {
|
|
205
|
+
if (typeof window !== "undefined" && window.scrollY > 0) {
|
|
206
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
onClick?.();
|
|
211
|
+
},
|
|
212
|
+
[disabled, selected, onClick],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const filledIcon = cloneIconWithFill(icon, selected);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<m.button
|
|
219
|
+
ref={ref}
|
|
220
|
+
type="button"
|
|
221
|
+
role="menuitem"
|
|
222
|
+
aria-current={selected ? "page" : undefined}
|
|
223
|
+
aria-disabled={disabled ? true : undefined}
|
|
224
|
+
aria-label={
|
|
225
|
+
ariaLabelProp || (typeof label === "string" ? label : undefined)
|
|
226
|
+
}
|
|
227
|
+
onClick={handleClick}
|
|
228
|
+
onPointerDown={onPointerDown}
|
|
229
|
+
className={cn(
|
|
230
|
+
"group relative flex flex-1 cursor-pointer transition-colors duration-200 outline-none select-none h-full",
|
|
231
|
+
variant === "xr"
|
|
232
|
+
? "items-center justify-center max-[599px]:min-w-28 max-[599px]:max-w-28 max-[599px]:items-start max-[599px]:pt-3 max-[599px]:pb-4"
|
|
233
|
+
: "items-center justify-center",
|
|
234
|
+
disabled && "pointer-events-none opacity-[0.38]",
|
|
235
|
+
className,
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
<div
|
|
239
|
+
className={cn(
|
|
240
|
+
"relative flex items-center justify-center flex-col gap-y-1 w-full",
|
|
241
|
+
isResponsiveHorizontal &&
|
|
242
|
+
"min-[600px]:flex-row min-[600px]:gap-y-0 min-[600px]:gap-x-1 min-[600px]:h-10 min-[600px]:px-4 min-[600px]:rounded-full min-[600px]:w-auto min-[600px]:max-w-42",
|
|
243
|
+
isForcedHorizontal &&
|
|
244
|
+
"flex-row gap-y-0 gap-x-1 h-10 px-4 rounded-full w-auto max-w-42",
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
{/* Horizontal active indicator — covers icon + label */}
|
|
248
|
+
<div
|
|
249
|
+
className={cn(
|
|
250
|
+
"absolute inset-0 z-0 hidden",
|
|
251
|
+
isResponsiveHorizontal && "min-[600px]:block",
|
|
252
|
+
isForcedHorizontal && "block!",
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<AnimatePresence initial={false}>
|
|
256
|
+
{selected && <ActivePill />}
|
|
257
|
+
</AnimatePresence>
|
|
258
|
+
<HoverStateLayer />
|
|
259
|
+
<RippleLayer ripples={ripples} onRippleDone={removeRipple} />
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Icon pill — background visible only in vertical layout */}
|
|
263
|
+
<div
|
|
264
|
+
className={cn(
|
|
265
|
+
"relative flex items-center justify-center shrink-0 z-10",
|
|
266
|
+
"h-8 w-16 mx-auto rounded-full",
|
|
267
|
+
isResponsiveHorizontal &&
|
|
268
|
+
"min-[600px]:size-6 min-[600px]:w-auto min-[600px]:h-auto",
|
|
269
|
+
isForcedHorizontal && "size-6 w-auto h-auto",
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<div
|
|
273
|
+
className={cn(
|
|
274
|
+
"absolute inset-0 z-0",
|
|
275
|
+
isResponsiveHorizontal && "min-[600px]:hidden",
|
|
276
|
+
isForcedHorizontal && "hidden",
|
|
277
|
+
)}
|
|
278
|
+
>
|
|
279
|
+
<AnimatePresence initial={false}>
|
|
280
|
+
{selected && <ActivePill />}
|
|
281
|
+
</AnimatePresence>
|
|
282
|
+
<HoverStateLayer />
|
|
283
|
+
<RippleLayer ripples={ripples} onRippleDone={removeRipple} />
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div className="relative z-10 flex size-6 items-center justify-center text-current">
|
|
287
|
+
<IconContainer selected={selected} badge={badge}>
|
|
288
|
+
{filledIcon}
|
|
289
|
+
</IconContainer>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<AnimatePresence mode="popLayout">
|
|
294
|
+
<span
|
|
295
|
+
key="nav-label"
|
|
296
|
+
className={cn(
|
|
297
|
+
"z-10 transition-all duration-200 truncate px-1",
|
|
298
|
+
selected ? "text-m3-on-surface" : "text-m3-on-surface-variant",
|
|
299
|
+
"font-medium text-[12px] leading-4 tracking-[0.5px]",
|
|
300
|
+
)}
|
|
301
|
+
>
|
|
302
|
+
{label}
|
|
303
|
+
</span>
|
|
304
|
+
</AnimatePresence>
|
|
305
|
+
</div>
|
|
306
|
+
<TouchTarget />
|
|
307
|
+
</m.button>
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
NavigationBarItemComponent.displayName = "NavigationBarItem";
|
|
313
|
+
export const NavigationBarItem = React.memo(NavigationBarItemComponent);
|
|
314
|
+
|
|
315
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
// NavigationBar Container
|
|
317
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
const navContainerVariants = cva(
|
|
320
|
+
"flex items-center justify-center select-none transition-transform duration-300 z-50",
|
|
321
|
+
{
|
|
322
|
+
variants: {
|
|
323
|
+
variant: {
|
|
324
|
+
flexible: "bottom-0 left-0 right-0 w-full h-16 pb-safe",
|
|
325
|
+
baseline: "bottom-0 left-0 right-0 w-full h-20 pb-safe",
|
|
326
|
+
xr: "bottom-6 left-1/2 -translate-x-1/2 w-auto max-w-fit h-20 min-[600px]:h-16 rounded-[48px] px-2",
|
|
327
|
+
},
|
|
328
|
+
position: {
|
|
329
|
+
fixed: "fixed",
|
|
330
|
+
absolute: "absolute",
|
|
331
|
+
},
|
|
332
|
+
elevated: {
|
|
333
|
+
true: "shadow-[0_-1px_3px_rgba(0,0,0,0.1)]",
|
|
334
|
+
false: "shadow-none",
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
defaultVariants: {
|
|
338
|
+
variant: "flexible",
|
|
339
|
+
position: "fixed",
|
|
340
|
+
elevated: false,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
export const NavigationBarComponent = React.forwardRef<
|
|
346
|
+
HTMLElement,
|
|
347
|
+
NavigationBarProps
|
|
348
|
+
>(
|
|
349
|
+
(
|
|
350
|
+
{
|
|
351
|
+
variant = "flexible",
|
|
352
|
+
itemLayout,
|
|
353
|
+
hideOnScroll = false,
|
|
354
|
+
elevated = false,
|
|
355
|
+
fixed = true,
|
|
356
|
+
scrollContainerRef,
|
|
357
|
+
activeIndicatorTransition,
|
|
358
|
+
children,
|
|
359
|
+
className,
|
|
360
|
+
style,
|
|
361
|
+
},
|
|
362
|
+
ref,
|
|
363
|
+
) => {
|
|
364
|
+
const [isVisible, setIsVisible] = React.useState(true);
|
|
365
|
+
const lastScrollY = React.useRef(
|
|
366
|
+
typeof window !== "undefined" ? window.scrollY : 0,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
React.useEffect(() => {
|
|
370
|
+
if (typeof window === "undefined" || !hideOnScroll) {
|
|
371
|
+
setIsVisible(true);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Do not hide if screen reader or reduced motion is active
|
|
376
|
+
const prefersReducedMotion = window.matchMedia(
|
|
377
|
+
"(prefers-reduced-motion: reduce)",
|
|
378
|
+
).matches;
|
|
379
|
+
if (prefersReducedMotion) {
|
|
380
|
+
setIsVisible(true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let ticking = false;
|
|
385
|
+
|
|
386
|
+
const handleScroll = () => {
|
|
387
|
+
if (ticking) return;
|
|
388
|
+
ticking = true;
|
|
389
|
+
window.requestAnimationFrame(() => {
|
|
390
|
+
const currentScrollY = scrollContainerRef?.current
|
|
391
|
+
? scrollContainerRef.current.scrollTop
|
|
392
|
+
: window.scrollY;
|
|
393
|
+
|
|
394
|
+
if (currentScrollY <= 0 || currentScrollY < lastScrollY.current) {
|
|
395
|
+
setIsVisible(true);
|
|
396
|
+
} else if (currentScrollY > lastScrollY.current) {
|
|
397
|
+
setIsVisible(false);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lastScrollY.current = currentScrollY;
|
|
401
|
+
ticking = false;
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const target = scrollContainerRef?.current || window;
|
|
406
|
+
|
|
407
|
+
target.addEventListener("scroll", handleScroll, { passive: true });
|
|
408
|
+
return () => target.removeEventListener("scroll", handleScroll);
|
|
409
|
+
}, [hideOnScroll, scrollContainerRef]);
|
|
410
|
+
|
|
411
|
+
const navBaseClasses = cn(
|
|
412
|
+
navContainerVariants({
|
|
413
|
+
variant,
|
|
414
|
+
elevated,
|
|
415
|
+
position: fixed ? "fixed" : "absolute",
|
|
416
|
+
}),
|
|
417
|
+
variant === "xr"
|
|
418
|
+
? "bg-m3-surface border border-white/5 shadow-xl"
|
|
419
|
+
: "bg-m3-surface-container",
|
|
420
|
+
className,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<LazyMotion features={domMax} strict>
|
|
425
|
+
<NavigationBarContext.Provider
|
|
426
|
+
value={{ variant, itemLayout, activeIndicatorTransition }}
|
|
427
|
+
>
|
|
428
|
+
<m.nav
|
|
429
|
+
ref={ref}
|
|
430
|
+
role="navigation"
|
|
431
|
+
aria-label="Main navigation"
|
|
432
|
+
className={navBaseClasses}
|
|
433
|
+
style={style}
|
|
434
|
+
initial={false}
|
|
435
|
+
animate={{
|
|
436
|
+
y: isVisible
|
|
437
|
+
? "0%"
|
|
438
|
+
: variant === "xr"
|
|
439
|
+
? "calc(100% + 40px)"
|
|
440
|
+
: "100%",
|
|
441
|
+
}}
|
|
442
|
+
transition={{ type: "tween", duration: 0.3, ease: "easeInOut" }}
|
|
443
|
+
>
|
|
444
|
+
<div
|
|
445
|
+
role="menubar"
|
|
446
|
+
aria-orientation="horizontal"
|
|
447
|
+
className={cn(
|
|
448
|
+
"flex w-full h-full mx-auto",
|
|
449
|
+
variant === "xr"
|
|
450
|
+
? "gap-0 min-[600px]:gap-1.5"
|
|
451
|
+
: "max-w-7xl gap-1.5",
|
|
452
|
+
)}
|
|
453
|
+
>
|
|
454
|
+
{children}
|
|
455
|
+
</div>
|
|
456
|
+
</m.nav>
|
|
457
|
+
</NavigationBarContext.Provider>
|
|
458
|
+
</LazyMotion>
|
|
459
|
+
);
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
NavigationBarComponent.displayName = "NavigationBar";
|
|
464
|
+
export const NavigationBar = React.memo(NavigationBarComponent);
|
|
@@ -373,9 +373,9 @@ describe("NavigationRail & NavigationRailItem", () => {
|
|
|
373
373
|
expect(nav).toHaveClass("w-20"); // narrow width
|
|
374
374
|
});
|
|
375
375
|
|
|
376
|
-
it("applies xr (spatial) styling when xr
|
|
376
|
+
it("applies xr (spatial) styling when variant='xr'", () => {
|
|
377
377
|
render(
|
|
378
|
-
<NavigationRail xr>
|
|
378
|
+
<NavigationRail variant="xr">
|
|
379
379
|
<NavigationRailItem selected icon={<svg />} label="Home" />
|
|
380
380
|
</NavigationRail>,
|
|
381
381
|
);
|
|
@@ -383,10 +383,11 @@ describe("NavigationRail & NavigationRailItem", () => {
|
|
|
383
383
|
expect(nav).toHaveClass("py-5", "rounded-[48px]", "bg-m3-surface");
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
-
it("renders spatial wrapper structurally when
|
|
386
|
+
it("renders spatial wrapper structurally when fabPlacement='spatialized'", () => {
|
|
387
387
|
render(
|
|
388
388
|
<NavigationRail
|
|
389
|
-
|
|
389
|
+
variant="xr"
|
|
390
|
+
fabPlacement="spatialized"
|
|
390
391
|
fab={
|
|
391
392
|
<button type="button" data-testid="rail-fab">
|
|
392
393
|
FAB
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { cva } from "class-variance-authority";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AnimatePresence,
|
|
4
|
+
domMax,
|
|
5
|
+
LazyMotion,
|
|
6
|
+
m,
|
|
7
|
+
type Transition,
|
|
8
|
+
} from "motion/react";
|
|
3
9
|
import * as React from "react";
|
|
4
10
|
import { createPortal } from "react-dom";
|
|
5
11
|
import { cn } from "../lib/utils";
|
|
@@ -15,7 +21,7 @@ import { TouchTarget } from "./shared/touch-target";
|
|
|
15
21
|
// Types & Constants
|
|
16
22
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
23
|
|
|
18
|
-
export type NavigationRailVariant = "collapsed" | "expanded" | "modal";
|
|
24
|
+
export type NavigationRailVariant = "collapsed" | "expanded" | "modal" | "xr";
|
|
19
25
|
export type NavigationRailLabelVisibility = "labeled" | "auto" | "unlabeled";
|
|
20
26
|
|
|
21
27
|
export interface NavigationRailItemProps {
|
|
@@ -34,11 +40,12 @@ export interface NavigationRailProps {
|
|
|
34
40
|
labelVisibility?: NavigationRailLabelVisibility;
|
|
35
41
|
header?: React.ReactNode;
|
|
36
42
|
fab?: React.ReactNode;
|
|
43
|
+
fabPlacement?: "contained" | "spatialized";
|
|
37
44
|
footer?: React.ReactNode;
|
|
38
45
|
narrow?: boolean;
|
|
39
46
|
open?: boolean;
|
|
40
|
-
xr?: boolean | "contained" | "spatialized";
|
|
41
47
|
onClose?: () => void;
|
|
48
|
+
activeIndicatorTransition?: Transition;
|
|
42
49
|
children: React.ReactNode;
|
|
43
50
|
className?: string;
|
|
44
51
|
style?: React.CSSProperties;
|
|
@@ -47,8 +54,8 @@ export interface NavigationRailProps {
|
|
|
47
54
|
const NavigationRailContext = React.createContext<{
|
|
48
55
|
variant: NavigationRailVariant;
|
|
49
56
|
labelVisibility: NavigationRailLabelVisibility;
|
|
50
|
-
|
|
51
|
-
}>({ variant: "collapsed", labelVisibility: "labeled"
|
|
57
|
+
activeIndicatorTransition?: Transition;
|
|
58
|
+
}>({ variant: "collapsed", labelVisibility: "labeled" });
|
|
52
59
|
|
|
53
60
|
const MD3_MODAL_TRANSITION = {
|
|
54
61
|
type: "tween",
|
|
@@ -65,19 +72,18 @@ const railContainerVariants = cva(
|
|
|
65
72
|
{
|
|
66
73
|
variants: {
|
|
67
74
|
variant: {
|
|
68
|
-
collapsed:
|
|
69
|
-
|
|
75
|
+
collapsed:
|
|
76
|
+
"items-center h-full pt-11 pb-4 shadow-none bg-m3-surface rounded-none",
|
|
77
|
+
expanded:
|
|
78
|
+
"items-start h-full pt-11 pb-4 shadow-none bg-m3-surface rounded-none",
|
|
70
79
|
modal:
|
|
71
|
-
"bg-m3-surface shadow-lg rounded-r-[var(--m3-shape-corner-large)]",
|
|
80
|
+
"bg-m3-surface shadow-lg rounded-r-[var(--m3-shape-corner-large)] h-full pt-11 pb-4",
|
|
81
|
+
xr: "h-fit py-5 rounded-[48px] shadow-xl bg-m3-surface border border-white/5",
|
|
72
82
|
},
|
|
73
83
|
narrow: {
|
|
74
84
|
true: "w-20",
|
|
75
85
|
false: "w-24",
|
|
76
86
|
},
|
|
77
|
-
xr: {
|
|
78
|
-
true: "h-fit py-5 rounded-[48px] shadow-xl bg-m3-surface border border-white/5",
|
|
79
|
-
false: "h-full pt-11 pb-4 shadow-none bg-m3-surface rounded-none",
|
|
80
|
-
},
|
|
81
87
|
},
|
|
82
88
|
compoundVariants: [
|
|
83
89
|
{ variant: "expanded", className: "min-w-[13.75rem] max-w-[22.5rem]" },
|
|
@@ -86,7 +92,6 @@ const railContainerVariants = cva(
|
|
|
86
92
|
defaultVariants: {
|
|
87
93
|
variant: "collapsed",
|
|
88
94
|
narrow: false,
|
|
89
|
-
xr: false,
|
|
90
95
|
},
|
|
91
96
|
},
|
|
92
97
|
);
|
|
@@ -136,6 +141,8 @@ interface ActivePillProps {
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
function ActivePill({ layoutId, disableInitial = false }: ActivePillProps) {
|
|
144
|
+
const { activeIndicatorTransition } = React.useContext(NavigationRailContext);
|
|
145
|
+
|
|
139
146
|
return (
|
|
140
147
|
<m.div
|
|
141
148
|
layoutId={layoutId}
|
|
@@ -144,7 +151,7 @@ function ActivePill({ layoutId, disableInitial = false }: ActivePillProps) {
|
|
|
144
151
|
initial={disableInitial ? false : { opacity: 0, scale: 0.5 }}
|
|
145
152
|
animate={{ opacity: 1, scale: 1 }}
|
|
146
153
|
exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.15 } }}
|
|
147
|
-
transition={SPRING_TRANSITION_EXPRESSIVE}
|
|
154
|
+
transition={activeIndicatorTransition || SPRING_TRANSITION_EXPRESSIVE}
|
|
148
155
|
/>
|
|
149
156
|
);
|
|
150
157
|
}
|
|
@@ -431,11 +438,12 @@ const NavigationRailComponent = React.forwardRef<
|
|
|
431
438
|
labelVisibility = "labeled",
|
|
432
439
|
header,
|
|
433
440
|
fab,
|
|
441
|
+
fabPlacement = "contained",
|
|
434
442
|
footer,
|
|
435
443
|
narrow = false,
|
|
436
444
|
open = false,
|
|
437
|
-
xr = false,
|
|
438
445
|
onClose,
|
|
446
|
+
activeIndicatorTransition,
|
|
439
447
|
children,
|
|
440
448
|
className,
|
|
441
449
|
style,
|
|
@@ -443,9 +451,8 @@ const NavigationRailComponent = React.forwardRef<
|
|
|
443
451
|
ref,
|
|
444
452
|
) => {
|
|
445
453
|
const isModal = variant === "modal";
|
|
446
|
-
const isXr =
|
|
447
|
-
const
|
|
448
|
-
const isSpatial = isXr && xrMode === "spatialized";
|
|
454
|
+
const isXr = variant === "xr";
|
|
455
|
+
const isSpatial = isXr && fabPlacement === "spatialized";
|
|
449
456
|
const applyAnimation = !isXr || !isSpatial;
|
|
450
457
|
|
|
451
458
|
const navRef = React.useRef<HTMLElement>(null);
|
|
@@ -460,14 +467,12 @@ const NavigationRailComponent = React.forwardRef<
|
|
|
460
467
|
[ref],
|
|
461
468
|
);
|
|
462
469
|
|
|
463
|
-
const navBaseClasses = cn(
|
|
464
|
-
railContainerVariants({ variant, narrow, xr: isXr }),
|
|
465
|
-
);
|
|
470
|
+
const navBaseClasses = cn(railContainerVariants({ variant, narrow }));
|
|
466
471
|
const modalPositioning = isModal ? "fixed left-0 top-0 z-[100]" : "";
|
|
467
472
|
|
|
468
473
|
const navHeaderSpacing = (() => {
|
|
469
474
|
if (!isXr) return "mb-6 min-h-10";
|
|
470
|
-
if (
|
|
475
|
+
if (fabPlacement === "contained") return fab ? "mb-10" : "mb-5";
|
|
471
476
|
return "mb-5";
|
|
472
477
|
})();
|
|
473
478
|
|
|
@@ -564,7 +569,11 @@ const NavigationRailComponent = React.forwardRef<
|
|
|
564
569
|
|
|
565
570
|
const finalNavElement = isSpatial ? spatialWrapper : navElement;
|
|
566
571
|
|
|
567
|
-
const contextValue = {
|
|
572
|
+
const contextValue = {
|
|
573
|
+
variant,
|
|
574
|
+
labelVisibility,
|
|
575
|
+
activeIndicatorTransition,
|
|
576
|
+
};
|
|
568
577
|
|
|
569
578
|
if (isModal) {
|
|
570
579
|
if (typeof document === "undefined") return null;
|
package/src/ui/scroll-area.tsx
CHANGED
|
@@ -32,6 +32,8 @@ export interface ScrollAreaProps
|
|
|
32
32
|
scrollHideDelay?: number;
|
|
33
33
|
/** Extra classes applied to the inner viewport element. */
|
|
34
34
|
viewportClassName?: string;
|
|
35
|
+
/** Ref to the scrolling viewport element. */
|
|
36
|
+
viewportRef?: React.Ref<HTMLDivElement>;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
@@ -47,6 +49,7 @@ const ScrollArea = React.forwardRef<
|
|
|
47
49
|
type = "hover",
|
|
48
50
|
orientation = "vertical",
|
|
49
51
|
scrollHideDelay = 600,
|
|
52
|
+
viewportRef,
|
|
50
53
|
...props
|
|
51
54
|
},
|
|
52
55
|
ref,
|
|
@@ -62,6 +65,7 @@ const ScrollArea = React.forwardRef<
|
|
|
62
65
|
{...props}
|
|
63
66
|
>
|
|
64
67
|
<RadixScrollArea.Viewport
|
|
68
|
+
ref={viewportRef}
|
|
65
69
|
className={cn(
|
|
66
70
|
"h-full w-full flex-1 min-h-0 min-w-0 rounded-[inherit]",
|
|
67
71
|
"outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-1",
|
|
@@ -22,7 +22,7 @@ import { AnimatePresence, m, useReducedMotion } from "motion/react";
|
|
|
22
22
|
import * as React from "react";
|
|
23
23
|
import { createPortal } from "react-dom";
|
|
24
24
|
import { cn } from "../../lib/utils";
|
|
25
|
-
import { IconButton } from "../icon-button";
|
|
25
|
+
import { IconButton } from "../buttons/icon-button";
|
|
26
26
|
import { AnimatedPlaceholder } from "./animated-placeholder";
|
|
27
27
|
import { useSearchViewFocus } from "./hooks/use-search-view-focus";
|
|
28
28
|
import {
|