@bug-on/md3-react 3.0.1 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bug-on/md3-react",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Material Design 3 Expressive React components",
5
5
  "author": "Bug Ổn",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -196,6 +196,14 @@ export {
196
196
  VerticalMenuGroup,
197
197
  VIBRANT_COLORS,
198
198
  } from "./ui/menu";
199
+ // Navigation Bar
200
+ export type {
201
+ NavigationBarItemProps,
202
+ NavigationBarItemLayout,
203
+ NavigationBarProps,
204
+ NavigationBarVariant,
205
+ } from "./ui/navigation-bar";
206
+ export { NavigationBar, NavigationBarItem } from "./ui/navigation-bar";
199
207
  // Navigation Rail
200
208
  export type {
201
209
  NavigationRailItemProps,
@@ -0,0 +1,111 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { NavigationBar, NavigationBarItem } from "./navigation-bar";
4
+
5
+ describe("NavigationBar", () => {
6
+ it("renders with correct role and aria-label", () => {
7
+ render(
8
+ <NavigationBar>
9
+ <NavigationBarItem selected icon={<span />} label="Home" />
10
+ </NavigationBar>,
11
+ );
12
+ const nav = screen.getByRole("navigation", { name: "Main navigation" });
13
+ expect(nav).toBeInTheDocument();
14
+ });
15
+
16
+ it("renders correct number of items", () => {
17
+ render(
18
+ <NavigationBar>
19
+ <NavigationBarItem selected icon={<span />} label="Home" />
20
+ <NavigationBarItem selected={false} icon={<span />} label="Search" />
21
+ </NavigationBar>,
22
+ );
23
+ const items = screen.getAllByRole("menuitem");
24
+ expect(items).toHaveLength(2);
25
+ });
26
+
27
+ it('marks selected item with aria-current="page"', () => {
28
+ render(
29
+ <NavigationBar>
30
+ <NavigationBarItem selected icon={<span />} label="Home" />
31
+ <NavigationBarItem selected={false} icon={<span />} label="Search" />
32
+ </NavigationBar>,
33
+ );
34
+ const selectedItem = screen.getByRole("menuitem", { current: "page" });
35
+ expect(selectedItem).toHaveTextContent("Home");
36
+ });
37
+
38
+ it("calls onClick when item clicked", () => {
39
+ const onClick = vi.fn();
40
+ render(
41
+ <NavigationBar>
42
+ <NavigationBarItem
43
+ selected={false}
44
+ icon={<span />}
45
+ label="Home"
46
+ onClick={onClick}
47
+ />
48
+ </NavigationBar>,
49
+ );
50
+ const item = screen.getByRole("menuitem");
51
+ fireEvent.click(item);
52
+ expect(onClick).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("does not call onClick when disabled", () => {
56
+ const onClick = vi.fn();
57
+ render(
58
+ <NavigationBar>
59
+ <NavigationBarItem
60
+ selected={false}
61
+ disabled
62
+ icon={<span />}
63
+ label="Home"
64
+ onClick={onClick}
65
+ />
66
+ </NavigationBar>,
67
+ );
68
+ const item = screen.getByRole("menuitem");
69
+ fireEvent.click(item);
70
+ expect(onClick).not.toHaveBeenCalled();
71
+ expect(item).toHaveAttribute("aria-disabled", "true");
72
+ });
73
+
74
+ it("renders badge when provided", () => {
75
+ render(
76
+ <NavigationBar>
77
+ <NavigationBarItem selected icon={<span />} label="Home" badge="1" />
78
+ </NavigationBar>,
79
+ );
80
+ const badge = screen.getByText("1");
81
+ expect(badge).toBeInTheDocument();
82
+ });
83
+
84
+ it("uses aria-label prop when provided", () => {
85
+ render(
86
+ <NavigationBar>
87
+ <NavigationBarItem
88
+ selected
89
+ icon={<span />}
90
+ label="Home"
91
+ aria-label="Go to homepage"
92
+ />
93
+ </NavigationBar>,
94
+ );
95
+ const item = screen.getByRole("menuitem");
96
+ expect(item).toHaveAttribute("aria-label", "Go to homepage");
97
+ });
98
+
99
+ it("renders xr variant with correct classes", () => {
100
+ render(
101
+ <NavigationBar variant="xr">
102
+ <NavigationBarItem selected icon={<span />} label="Home" />
103
+ </NavigationBar>,
104
+ );
105
+ const nav = screen.getByRole("navigation", { name: "Main navigation" });
106
+ expect(nav).toHaveClass("bottom-6");
107
+ expect(nav).toHaveClass("left-1/2");
108
+ expect(nav).toHaveClass("-translate-x-1/2");
109
+ expect(nav).toHaveClass("rounded-[48px]");
110
+ });
111
+ });
@@ -0,0 +1,448 @@
1
+ "use client";
2
+
3
+ import { cva } from "class-variance-authority";
4
+ import { AnimatePresence, domMax, LazyMotion, m, type Transition } from "motion/react";
5
+ import * as React from "react";
6
+ import { cn } from "../lib/utils";
7
+ import { Icon } from "./icon";
8
+ import { Ripple, useRippleState } from "./ripple";
9
+ import { SPRING_TRANSITION_EXPRESSIVE } from "./shared/constants";
10
+ import { TouchTarget } from "./shared/touch-target";
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Types
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Layout styling for navigation bar items.
18
+ * - vertical: Icon above label (default for mobile)
19
+ * - horizontal: Icon beside label (forced)
20
+ */
21
+ export type NavigationBarItemLayout = "vertical" | "horizontal";
22
+
23
+ /**
24
+ * Visual variant of the Navigation Bar.
25
+ * - flexible: Default MD3 behavior (h-16), becomes horizontal on desktop.
26
+ * - baseline: Taller MD3 behavior (h-20), always vertical.
27
+ * - xr: Floating orbiter variant for spatial interfaces (detached from bottom).
28
+ */
29
+ export type NavigationBarVariant = "flexible" | "baseline" | "xr";
30
+
31
+ export interface NavigationBarItemProps {
32
+ selected: boolean;
33
+ icon: React.ReactNode;
34
+ label: React.ReactNode;
35
+ onClick?: () => void;
36
+ disabled?: boolean;
37
+ badge?: React.ReactNode;
38
+ "aria-label"?: string;
39
+ className?: string;
40
+ }
41
+
42
+ export interface NavigationBarProps {
43
+ /** Visual variant of the Navigation Bar */
44
+ variant?: NavigationBarVariant;
45
+ /** Forces a specific item layout (horizontal/vertical) */
46
+ itemLayout?: NavigationBarItemLayout;
47
+ /** Whether the bar should hide when scrolling down */
48
+ hideOnScroll?: boolean;
49
+ /** Whether the bar should have an elevation shadow */
50
+ elevated?: boolean;
51
+ /** Whether the bar is fixed to the viewport (default) or absolute */
52
+ fixed?: boolean;
53
+ /** Container ref to track scrolling for hideOnScroll */
54
+ scrollContainerRef?: React.RefObject<HTMLElement | null>;
55
+ /** Transition for the active indicator pill */
56
+ activeIndicatorTransition?: Transition;
57
+ /** Navigation items */
58
+ children: React.ReactNode;
59
+ /** Optional additional classes */
60
+ className?: string;
61
+ /** Optional inline styles */
62
+ style?: React.CSSProperties;
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Context
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ const NavigationBarContext = React.createContext<{
70
+ variant: NavigationBarVariant;
71
+ itemLayout?: NavigationBarItemLayout;
72
+ activeIndicatorTransition?: Transition;
73
+ }>({ variant: "flexible" });
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // Helpers
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+
79
+ function cloneIconWithFill(
80
+ icon: React.ReactNode,
81
+ selected: boolean,
82
+ ): React.ReactNode {
83
+ if (!React.isValidElement(icon)) return icon;
84
+ if ((icon.type as unknown) === Icon) {
85
+ return React.cloneElement(
86
+ icon as React.ReactElement<{ fill?: 0 | 1; animateFill?: boolean }>,
87
+ { fill: selected ? 1 : 0, animateFill: true },
88
+ );
89
+ }
90
+ return icon;
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // NavigationBarItem Sub-components
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ function ActivePill() {
98
+ const { activeIndicatorTransition } = React.useContext(NavigationBarContext);
99
+
100
+ return (
101
+ <m.div
102
+ className="absolute inset-0 bg-m3-secondary-container pointer-events-none"
103
+ style={{
104
+ borderRadius: 9999,
105
+ zIndex: 0,
106
+ }}
107
+ initial={{ opacity: 0, scale: 0.5 }}
108
+ animate={{ opacity: 1, scale: 1 }}
109
+ exit={{ opacity: 0, scale: 0.5 }}
110
+ transition={activeIndicatorTransition || SPRING_TRANSITION_EXPRESSIVE}
111
+ />
112
+ );
113
+ }
114
+
115
+ function HoverStateLayer() {
116
+ return (
117
+ <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" />
118
+ );
119
+ }
120
+
121
+ interface RippleLayerProps {
122
+ ripples: ReturnType<typeof useRippleState>["ripples"];
123
+ onRippleDone: ReturnType<typeof useRippleState>["removeRipple"];
124
+ }
125
+
126
+ function RippleLayer({ ripples, onRippleDone }: RippleLayerProps) {
127
+ return (
128
+ <div className="absolute inset-0 rounded-full overflow-hidden pointer-events-none z-0">
129
+ <Ripple ripples={ripples} onRippleDone={onRippleDone} />
130
+ </div>
131
+ );
132
+ }
133
+
134
+ interface IconContainerProps {
135
+ selected: boolean;
136
+ badge?: React.ReactNode;
137
+ children: React.ReactNode;
138
+ }
139
+
140
+ function IconContainer({ selected, badge, children }: IconContainerProps) {
141
+ return (
142
+ <div
143
+ aria-hidden="true"
144
+ className={cn(
145
+ "relative flex items-center justify-center size-6 transition-colors duration-200 shrink-0",
146
+ selected
147
+ ? "text-m3-on-secondary-container"
148
+ : "text-m3-on-surface-variant",
149
+ )}
150
+ >
151
+ {children}
152
+ {badge && (
153
+ <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">
154
+ {badge}
155
+ </span>
156
+ )}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+ // NavigationBarItem
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ const NavigationBarItemComponent = React.forwardRef<
166
+ HTMLButtonElement,
167
+ NavigationBarItemProps
168
+ >(
169
+ (
170
+ {
171
+ selected,
172
+ icon,
173
+ label,
174
+ onClick,
175
+ disabled = false,
176
+ badge,
177
+ className,
178
+ "aria-label": ariaLabelProp,
179
+ },
180
+ ref,
181
+ ) => {
182
+ const { variant, itemLayout } = React.useContext(NavigationBarContext);
183
+
184
+ const isForcedHorizontal = itemLayout === "horizontal";
185
+ const isResponsiveHorizontal =
186
+ (variant === "flexible" || variant === "xr") && itemLayout === undefined;
187
+
188
+ const { ripples, onPointerDown, removeRipple } = useRippleState({
189
+ disabled,
190
+ });
191
+
192
+ const handleClick = React.useCallback(
193
+ (e: React.MouseEvent<HTMLButtonElement>) => {
194
+ if (disabled) {
195
+ e.preventDefault();
196
+ return;
197
+ }
198
+ if (selected) {
199
+ if (typeof window !== "undefined" && window.scrollY > 0) {
200
+ window.scrollTo({ top: 0, behavior: "smooth" });
201
+ }
202
+ return;
203
+ }
204
+ onClick?.();
205
+ },
206
+ [disabled, selected, onClick],
207
+ );
208
+
209
+ const filledIcon = cloneIconWithFill(icon, selected);
210
+
211
+ return (
212
+ <m.button
213
+ ref={ref}
214
+ type="button"
215
+ role="menuitem"
216
+ aria-current={selected ? "page" : undefined}
217
+ aria-disabled={disabled ? true : undefined}
218
+ aria-label={
219
+ ariaLabelProp || (typeof label === "string" ? label : undefined)
220
+ }
221
+ onClick={handleClick}
222
+ onPointerDown={onPointerDown}
223
+ className={cn(
224
+ "group relative flex flex-1 cursor-pointer transition-colors duration-200 outline-none select-none h-full",
225
+ variant === "xr"
226
+ ? "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"
227
+ : "items-center justify-center",
228
+ disabled && "pointer-events-none opacity-[0.38]",
229
+ className,
230
+ )}
231
+ >
232
+ <div
233
+ className={cn(
234
+ "relative flex items-center justify-center flex-col gap-y-1 w-full",
235
+ isResponsiveHorizontal &&
236
+ "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",
237
+ isForcedHorizontal &&
238
+ "flex-row gap-y-0 gap-x-1 h-10 px-4 rounded-full w-auto max-w-42",
239
+ )}
240
+ >
241
+ {/* Horizontal active indicator — covers icon + label */}
242
+ <div
243
+ className={cn(
244
+ "absolute inset-0 z-0 hidden",
245
+ isResponsiveHorizontal && "min-[600px]:block",
246
+ isForcedHorizontal && "block!",
247
+ )}
248
+ >
249
+ <AnimatePresence initial={false}>
250
+ {selected && <ActivePill />}
251
+ </AnimatePresence>
252
+ <HoverStateLayer />
253
+ <RippleLayer ripples={ripples} onRippleDone={removeRipple} />
254
+ </div>
255
+
256
+ {/* Icon pill — background visible only in vertical layout */}
257
+ <div
258
+ className={cn(
259
+ "relative flex items-center justify-center shrink-0 z-10",
260
+ "h-8 w-16 mx-auto rounded-full",
261
+ isResponsiveHorizontal &&
262
+ "min-[600px]:size-6 min-[600px]:w-auto min-[600px]:h-auto",
263
+ isForcedHorizontal && "size-6 w-auto h-auto",
264
+ )}
265
+ >
266
+ <div
267
+ className={cn(
268
+ "absolute inset-0 z-0",
269
+ isResponsiveHorizontal && "min-[600px]:hidden",
270
+ isForcedHorizontal && "hidden",
271
+ )}
272
+ >
273
+ <AnimatePresence initial={false}>
274
+ {selected && <ActivePill />}
275
+ </AnimatePresence>
276
+ <HoverStateLayer />
277
+ <RippleLayer ripples={ripples} onRippleDone={removeRipple} />
278
+ </div>
279
+
280
+ <div className="relative z-10 flex size-6 items-center justify-center text-current">
281
+ <IconContainer selected={selected} badge={badge}>
282
+ {filledIcon}
283
+ </IconContainer>
284
+ </div>
285
+ </div>
286
+
287
+ <AnimatePresence mode="popLayout">
288
+ <span
289
+ key="nav-label"
290
+ className={cn(
291
+ "z-10 transition-all duration-200 truncate px-1",
292
+ selected ? "text-m3-on-surface" : "text-m3-on-surface-variant",
293
+ "font-medium text-[12px] leading-4 tracking-[0.5px]",
294
+ )}
295
+ >
296
+ {label}
297
+ </span>
298
+ </AnimatePresence>
299
+ </div>
300
+ <TouchTarget />
301
+ </m.button>
302
+ );
303
+ },
304
+ );
305
+
306
+ NavigationBarItemComponent.displayName = "NavigationBarItem";
307
+ export const NavigationBarItem = React.memo(NavigationBarItemComponent);
308
+
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+ // NavigationBar Container
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+
313
+ const navContainerVariants = cva(
314
+ "flex items-center justify-center select-none transition-transform duration-300 z-50",
315
+ {
316
+ variants: {
317
+ variant: {
318
+ flexible: "bottom-0 left-0 right-0 w-full h-16 pb-safe",
319
+ baseline: "bottom-0 left-0 right-0 w-full h-20 pb-safe",
320
+ 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",
321
+ },
322
+ position: {
323
+ fixed: "fixed",
324
+ absolute: "absolute",
325
+ },
326
+ elevated: {
327
+ true: "shadow-[0_-1px_3px_rgba(0,0,0,0.1)]",
328
+ false: "shadow-none",
329
+ },
330
+ },
331
+ defaultVariants: {
332
+ variant: "flexible",
333
+ position: "fixed",
334
+ elevated: false,
335
+ },
336
+ },
337
+ );
338
+
339
+ export const NavigationBarComponent = React.forwardRef<
340
+ HTMLElement,
341
+ NavigationBarProps
342
+ >(
343
+ (
344
+ {
345
+ variant = "flexible",
346
+ itemLayout,
347
+ hideOnScroll = false,
348
+ elevated = false,
349
+ fixed = true,
350
+ scrollContainerRef,
351
+ activeIndicatorTransition,
352
+ children,
353
+ className,
354
+ style,
355
+ },
356
+ ref,
357
+ ) => {
358
+ const [isVisible, setIsVisible] = React.useState(true);
359
+ const lastScrollY = React.useRef(
360
+ typeof window !== "undefined" ? window.scrollY : 0,
361
+ );
362
+
363
+ React.useEffect(() => {
364
+ if (typeof window === "undefined" || !hideOnScroll) {
365
+ setIsVisible(true);
366
+ return;
367
+ }
368
+
369
+ // Do not hide if screen reader or reduced motion is active
370
+ const prefersReducedMotion = window.matchMedia(
371
+ "(prefers-reduced-motion: reduce)",
372
+ ).matches;
373
+ if (prefersReducedMotion) {
374
+ setIsVisible(true);
375
+ return;
376
+ }
377
+
378
+ let ticking = false;
379
+
380
+ const handleScroll = () => {
381
+ if (ticking) return;
382
+ ticking = true;
383
+ window.requestAnimationFrame(() => {
384
+ const currentScrollY = scrollContainerRef?.current
385
+ ? scrollContainerRef.current.scrollTop
386
+ : window.scrollY;
387
+
388
+ if (currentScrollY <= 0 || currentScrollY < lastScrollY.current) {
389
+ setIsVisible(true);
390
+ } else if (currentScrollY > lastScrollY.current) {
391
+ setIsVisible(false);
392
+ }
393
+
394
+ lastScrollY.current = currentScrollY;
395
+ ticking = false;
396
+ });
397
+ };
398
+
399
+ const target = scrollContainerRef?.current || window;
400
+
401
+ target.addEventListener("scroll", handleScroll, { passive: true });
402
+ return () => target.removeEventListener("scroll", handleScroll);
403
+ }, [hideOnScroll, scrollContainerRef]);
404
+
405
+ const navBaseClasses = cn(
406
+ navContainerVariants({
407
+ variant,
408
+ elevated,
409
+ position: fixed ? "fixed" : "absolute",
410
+ }),
411
+ variant === "xr"
412
+ ? "bg-m3-surface border border-white/5 shadow-xl"
413
+ : "bg-m3-surface-container",
414
+ className,
415
+ );
416
+
417
+ return (
418
+ <LazyMotion features={domMax} strict>
419
+ <NavigationBarContext.Provider value={{ variant, itemLayout, activeIndicatorTransition }}>
420
+ <m.nav
421
+ ref={ref}
422
+ role="navigation"
423
+ aria-label="Main navigation"
424
+ className={navBaseClasses}
425
+ style={style}
426
+ initial={false}
427
+ animate={{ y: isVisible ? "0%" : "100%" }}
428
+ transition={{ type: "tween", duration: 0.3, ease: "easeInOut" }}
429
+ >
430
+ <div
431
+ role="menubar"
432
+ aria-orientation="horizontal"
433
+ className={cn(
434
+ "flex w-full h-full mx-auto",
435
+ variant === "xr" ? "gap-0 min-[600px]:gap-1.5" : "max-w-7xl gap-1.5",
436
+ )}
437
+ >
438
+ {children}
439
+ </div>
440
+ </m.nav>
441
+ </NavigationBarContext.Provider>
442
+ </LazyMotion>
443
+ );
444
+ },
445
+ );
446
+
447
+ NavigationBarComponent.displayName = "NavigationBar";
448
+ 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={true}", () => {
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 xr='spatialized'", () => {
386
+ it("renders spatial wrapper structurally when fabPlacement='spatialized'", () => {
387
387
  render(
388
388
  <NavigationRail
389
- xr="spatialized"
389
+ variant="xr"
390
+ fabPlacement="spatialized"
390
391
  fab={
391
392
  <button type="button" data-testid="rail-fab">
392
393
  FAB