@bug-on/md3-react 3.0.0 → 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.
@@ -0,0 +1 @@
1
+ export { default } from '@bug-on/md3-tailwind';
@@ -0,0 +1 @@
1
+ export { default } from '@bug-on/md3-tailwind';
package/dist/plugin.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ var md3Tailwind = require('@bug-on/md3-tailwind');
4
+
5
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
+
7
+ var md3Tailwind__default = /*#__PURE__*/_interopDefault(md3Tailwind);
8
+
9
+
10
+
11
+ module.exports = md3Tailwind__default.default;
12
+ //# sourceMappingURL=plugin.js.map
13
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"plugin.js","sourcesContent":[]}
@@ -0,0 +1,3 @@
1
+ export { default } from '@bug-on/md3-tailwind';
2
+ //# sourceMappingURL=plugin.mjs.map
3
+ //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"plugin.mjs","sourcesContent":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bug-on/md3-react",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Material Design 3 Expressive React components",
5
5
  "author": "Bug Ổn",
6
6
  "license": "MIT",
@@ -42,6 +42,11 @@
42
42
  "./material-symbols-self-hosted.css": {
43
43
  "types": "./dist/material-symbols-self-hosted.css.d.ts",
44
44
  "default": "./dist/material-symbols-self-hosted.css"
45
+ },
46
+ "./plugin": {
47
+ "types": "./dist/plugin.d.ts",
48
+ "import": "./dist/plugin.mjs",
49
+ "require": "./dist/plugin.js"
45
50
  }
46
51
  },
47
52
  "peerDependencies": {
@@ -64,7 +69,8 @@
64
69
  "class-variance-authority": "^0.7.1",
65
70
  "clsx": "^2.1.1",
66
71
  "tailwind-merge": "^3.3.1",
67
- "@bug-on/md3-tokens": "3.0.0"
72
+ "@bug-on/md3-tokens": "3.0.1",
73
+ "@bug-on/md3-tailwind": "3.0.1"
68
74
  },
69
75
  "devDependencies": {
70
76
  "@testing-library/jest-dom": "^6.9.1",
@@ -26,11 +26,42 @@ if (fs.existsSync(srcCss)) {
26
26
  console.log("✅ Copied typography.css to dist/typography.css");
27
27
  }
28
28
 
29
+ // Bundle index.css = md3-tokens CSS + react component base styles.
30
+ // This ensures users only need to import @bug-on/md3-react/index.css
31
+ // and get all required design tokens automatically.
32
+ const tokensCssDir = path.join(__dirname, "../../tokens/dist");
33
+ const tokensColorsCss = path.join(tokensCssDir, "colors.css");
34
+ const tokensShapeCss = path.join(tokensCssDir, "shape.css");
35
+
36
+ const bundleParts = [
37
+ "/* @bug-on/md3-tokens — MD3 System Color Tokens */",
38
+ ];
39
+
40
+ if (fs.existsSync(tokensColorsCss)) {
41
+ bundleParts.push(fs.readFileSync(tokensColorsCss, "utf-8"));
42
+ console.log("✅ Bundled colors.css from md3-tokens into index.css");
43
+ } else {
44
+ console.warn("⚠️ md3-tokens colors.css not found — build tokens first");
45
+ }
46
+
47
+ if (fs.existsSync(tokensShapeCss)) {
48
+ bundleParts.push(fs.readFileSync(tokensShapeCss, "utf-8"));
49
+ console.log("✅ Bundled shape.css from md3-tokens into index.css");
50
+ } else {
51
+ console.warn("⚠️ md3-tokens shape.css not found — build tokens first");
52
+ }
53
+
29
54
  if (fs.existsSync(srcIndexCss)) {
30
- fs.copyFileSync(srcIndexCss, distIndexCss);
31
- console.log(" Copied index.css to dist/index.css");
55
+ bundleParts.push(
56
+ "\n/* @bug-on/md3-react Component Base Reset */",
57
+ fs.readFileSync(srcIndexCss, "utf-8"),
58
+ );
32
59
  }
33
60
 
61
+ fs.writeFileSync(distIndexCss, bundleParts.join("\n"));
62
+ console.log("✅ Built bundled index.css (tokens + component base) to dist/index.css");
63
+
64
+
34
65
  // Copy Material Symbols CSS variants
35
66
  const cssVariants = [
36
67
  "material-symbols-cdn.css",
@@ -62,7 +93,9 @@ for (const stub of cssStubs) {
62
93
  console.log(`✅ Generated ${stub}`);
63
94
  }
64
95
 
65
- // Prepend "use client" to JS/MJS output files to support React Server Components
96
+ // Prepend "use client" to JS/MJS output files to support React Server Components.
97
+ // NOTE: Using post-build script instead of tsup banner — banner causes esbuild
98
+ // "Module level directives" warnings in library bundling context.
66
99
  const distFiles = [
67
100
  path.join(__dirname, "../dist/index.js"),
68
101
  path.join(__dirname, "../dist/index.mjs"),
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ export { useRipple as useDOMRipple } from "./hooks/useRipple";
5
5
  export { MaterialSymbolsPreconnect } from "./lib/material-symbols-preconnect";
6
6
  // Theme — MD3 Dynamic Color
7
7
  export type { MD3ColorScheme, ThemeMode } from "./lib/theme-utils";
8
- export { applyTheme, generateM3Theme } from "./lib/theme-utils";
8
+ export { applyTheme, generateM3Theme, resolveMode } from "./lib/theme-utils";
9
9
  // Utils
10
10
  export { cn } from "./lib/utils";
11
11
  // Types
@@ -4,7 +4,20 @@ import {
4
4
  themeFromSourceColor,
5
5
  } from "@material/material-color-utilities";
6
6
 
7
- export type ThemeMode = "light" | "dark";
7
+ export type ThemeMode = "light" | "dark" | "system";
8
+
9
+ /**
10
+ * Resolves the effective color scheme from a ThemeMode.
11
+ * When mode is "system", reads the OS preference via matchMedia.
12
+ * Returns "light" as the safe default in SSR environments.
13
+ */
14
+ export function resolveMode(mode: ThemeMode): "light" | "dark" {
15
+ if (mode !== "system") return mode;
16
+ if (typeof window === "undefined") return "light";
17
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
18
+ ? "dark"
19
+ : "light";
20
+ }
8
21
 
9
22
  export interface MD3ColorScheme {
10
23
  primary: string;
@@ -70,7 +83,7 @@ export interface MD3ColorScheme {
70
83
  */
71
84
  export function generateM3Theme(
72
85
  sourceColorHex: string,
73
- mode: ThemeMode = "light",
86
+ mode: "light" | "dark" = "light",
74
87
  ): MD3ColorScheme {
75
88
  const sourceColor = argbFromHex(sourceColorHex);
76
89
  const theme = themeFromSourceColor(sourceColor);
@@ -168,7 +181,8 @@ export function applyTheme(
168
181
  mode: ThemeMode = "light",
169
182
  root: HTMLElement = document.documentElement,
170
183
  ): void {
171
- const colors = generateM3Theme(sourceColorHex, mode);
184
+ const resolved = resolveMode(mode);
185
+ const colors = generateM3Theme(sourceColorHex, resolved);
172
186
 
173
187
  for (const [key, value] of Object.entries(colors)) {
174
188
  const kebabKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
@@ -176,5 +190,6 @@ export function applyTheme(
176
190
  root.style.setProperty(`--color-m3-${kebabKey}`, value);
177
191
  }
178
192
 
179
- root.setAttribute("data-theme", mode);
193
+ // data-theme is always "light" or "dark" — never "system"
194
+ root.setAttribute("data-theme", resolved);
180
195
  }
package/src/plugin.ts ADDED
@@ -0,0 +1,12 @@
1
+ // Re-export the MD3 Tailwind plugin from @bug-on/md3-tailwind.
2
+ // This allows users to reference the plugin directly from @bug-on/md3-react
3
+ // without installing @bug-on/md3-tailwind separately.
4
+ //
5
+ // Usage in CSS (Tailwind v4):
6
+ // @import "@bug-on/md3-react/index.css";
7
+ // @plugin "@bug-on/md3-react/plugin";
8
+ //
9
+ // Usage in tailwind.config.ts (Tailwind v3):
10
+ // import md3Plugin from "@bug-on/md3-react/plugin";
11
+ // export default { plugins: [md3Plugin] };
12
+ export { default } from "@bug-on/md3-tailwind";
@@ -161,22 +161,20 @@ describe("Button Component", () => {
161
161
  });
162
162
  });
163
163
 
164
- // ── New: Sentence-case label ───────────────────────────────────────────────
165
-
166
- describe("sentence-case label", () => {
167
- it("converts ALL CAPS to sentence-case", () => {
164
+ describe("label case", () => {
165
+ it("preserves ALL CAPS", () => {
168
166
  render(<Button>HELLO WORLD</Button>);
169
- expect(screen.getByRole("button")).toHaveTextContent("Hello world");
167
+ expect(screen.getByRole("button")).toHaveTextContent("HELLO WORLD");
170
168
  });
171
169
 
172
- it("converts all-lowercase to sentence-case", () => {
170
+ it("preserves all-lowercase", () => {
173
171
  render(<Button>hello world</Button>);
174
- expect(screen.getByRole("button")).toHaveTextContent("Hello world");
172
+ expect(screen.getByRole("button")).toHaveTextContent("hello world");
175
173
  });
176
174
 
177
- it("handles mixed case correctly", () => {
175
+ it("preserves mixed case", () => {
178
176
  render(<Button>hElLo WoRlD</Button>);
179
- expect(screen.getByRole("button")).toHaveTextContent("Hello world");
177
+ expect(screen.getByRole("button")).toHaveTextContent("hElLo WoRlD");
180
178
  });
181
179
  });
182
180
 
@@ -215,7 +213,7 @@ describe("Button Component", () => {
215
213
  s.querySelector("[data-testid='test-icon']"),
216
214
  );
217
215
  const labelIndex = spans.findIndex((s) =>
218
- s.textContent?.includes("Rtl trailing"),
216
+ s.textContent?.includes("RTL Trailing"),
219
217
  );
220
218
  // DOM order is still trailing (CSS handles visual flip in RTL via flex)
221
219
  expect(iconIndex).toBeGreaterThan(labelIndex);
@@ -270,6 +268,17 @@ describe("Button Component", () => {
270
268
  "transition-[background-color,color,border-color,box-shadow,opacity,filter]",
271
269
  );
272
270
  });
271
+
272
+ it("applies h-full to the label wrapper for vertical alignment", () => {
273
+ render(
274
+ <Button asChild>
275
+ <a href="/test">Aligned Link</a>
276
+ </Button>,
277
+ );
278
+ const link = screen.getByRole("link");
279
+ const labelWrapper = link.querySelector("span.inline-flex.items-center");
280
+ expect(labelWrapper).toHaveClass("h-full");
281
+ });
273
282
  });
274
283
 
275
284
  // ── New: loading prop ──────────────────────────────────────────────────────
package/src/ui/button.tsx CHANGED
@@ -294,10 +294,6 @@ export type ButtonProps = BaseButtonProps &
294
294
 
295
295
  // ─── Helpers ───────────────────────────────────────────────────────────────────
296
296
 
297
- function toSentenceCase(text: string): string {
298
- return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
299
- }
300
-
301
297
  function resolveLabel(
302
298
  children: React.ReactNode,
303
299
  asChild: boolean,
@@ -308,7 +304,7 @@ function resolveLabel(
308
304
  }>;
309
305
  return child.props.children;
310
306
  }
311
- return typeof children === "string" ? toSentenceCase(children) : children;
307
+ return children;
312
308
  }
313
309
 
314
310
  /** Framer Motion-specific props to strip before forwarding to a plain DOM element. */
@@ -550,7 +546,7 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
550
546
 
551
547
  <m.span
552
548
  layout="size"
553
- className="inline-flex items-center gap-[inherit]"
549
+ className="inline-flex items-center h-full gap-[inherit]"
554
550
  transition={SPRING_TRANSITION}
555
551
  >
556
552
  {labelText}
@@ -5,7 +5,10 @@ import { createPortal } from "react-dom";
5
5
  import { cn } from "../lib/utils";
6
6
  import { Icon } from "./icon";
7
7
  import { Ripple, useRippleState } from "./ripple";
8
- import { SPRING_TRANSITION } from "./shared/constants";
8
+ import {
9
+ SPRING_TRANSITION,
10
+ SPRING_TRANSITION_EXPRESSIVE,
11
+ } from "./shared/constants";
9
12
  import { TouchTarget } from "./shared/touch-target";
10
13
 
11
14
  // ─────────────────────────────────────────────────────────────────────────────
@@ -137,11 +140,11 @@ function ActivePill({ layoutId, disableInitial = false }: ActivePillProps) {
137
140
  <m.div
138
141
  layoutId={layoutId}
139
142
  className="absolute inset-0 bg-m3-secondary-container pointer-events-none"
140
- style={{ borderRadius: 9999, zIndex: 0 }}
141
- initial={disableInitial ? false : { opacity: 0 }}
142
- animate={{ opacity: 1 }}
143
- exit={{ opacity: 0 }}
144
- transition={SPRING_TRANSITION}
143
+ style={{ borderRadius: 9999, zIndex: 0, originX: 0.5, originY: 0.5 }}
144
+ initial={disableInitial ? false : { opacity: 0, scale: 0.5 }}
145
+ animate={{ opacity: 1, scale: 1 }}
146
+ exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.15 } }}
147
+ transition={SPRING_TRANSITION_EXPRESSIVE}
145
148
  />
146
149
  );
147
150
  }
@@ -48,6 +48,19 @@ export const SPRING_TRANSITION: Transition = {
48
48
  duration: 0.3,
49
49
  } as const;
50
50
 
51
+ /**
52
+ * MD3 Expressive spring — active indicator expand/collapse.
53
+ * Higher bounce for the "pop" effect per MD3 Expressive spec.
54
+ *
55
+ * - Duration: 400ms
56
+ * - Bounce: 0.35 (spring overshoot → lò xo)
57
+ */
58
+ export const SPRING_TRANSITION_EXPRESSIVE: Transition = {
59
+ type: "spring",
60
+ bounce: 0.35,
61
+ duration: 0.4,
62
+ } as const;
63
+
51
64
  // ─────────────────────────────────────────────────────────────────────────────
52
65
  // Icon Span Motion Variants
53
66
  // Used for icon/loading indicator swap animation inside FAB and IconButton.
@@ -9,7 +9,7 @@ import {
9
9
  useMemo,
10
10
  useState,
11
11
  } from "react";
12
- import { applyTheme, type ThemeMode } from "../../lib/theme-utils";
12
+ import { applyTheme, resolveMode, type ThemeMode } from "../../lib/theme-utils";
13
13
  import {
14
14
  SnackbarContext,
15
15
  SnackbarHost,
@@ -28,6 +28,8 @@ interface ThemeContextValue {
28
28
  setSourceColor: (color: string) => void;
29
29
  mode: ThemeMode;
30
30
  setMode: (mode: ThemeMode) => void;
31
+ /** The resolved color scheme actually applied — always "light" or "dark". */
32
+ effectiveMode: "light" | "dark";
31
33
  }
32
34
 
33
35
  const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
@@ -104,7 +106,12 @@ export function MD3ThemeProvider({
104
106
  ) as ThemeMode | null;
105
107
 
106
108
  if (savedColor) setSourceColor(savedColor);
107
- if (savedMode === "light" || savedMode === "dark") setMode(savedMode);
109
+ if (
110
+ savedMode === "light" ||
111
+ savedMode === "dark" ||
112
+ savedMode === "system"
113
+ )
114
+ setMode(savedMode);
108
115
 
109
116
  setIsHydrated(true);
110
117
  }, [persistToLocalStorage]);
@@ -120,9 +127,24 @@ export function MD3ThemeProvider({
120
127
  }
121
128
  }, [sourceColor, mode, persistToLocalStorage, isHydrated]);
122
129
 
130
+ // ── System preference subscription ───────────────────────────────────────
131
+ // When mode is "system", listen for OS-level dark/light changes in real time
132
+ useEffect(() => {
133
+ if (mode !== "system" || typeof window === "undefined") return;
134
+
135
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
136
+ const handleChange = () => applyTheme(sourceColor, "system");
137
+
138
+ mediaQuery.addEventListener("change", handleChange);
139
+ return () => mediaQuery.removeEventListener("change", handleChange);
140
+ }, [mode, sourceColor]);
141
+
142
+ // ── Derived effective mode (no extra state needed) ────────────────────────
143
+ const effectiveMode = resolveMode(mode);
144
+
123
145
  const themeValue = useMemo<ThemeContextValue>(
124
- () => ({ sourceColor, setSourceColor, mode, setMode }),
125
- [sourceColor, mode],
146
+ () => ({ sourceColor, setSourceColor, mode, setMode, effectiveMode }),
147
+ [sourceColor, mode, effectiveMode],
126
148
  );
127
149
 
128
150
  // ── Typography value ─────────────────────────────────────────────────────
@@ -184,7 +206,10 @@ export function useTheme(): ThemeContextValue {
184
206
  return context;
185
207
  }
186
208
 
187
- export function useThemeMode(): Pick<ThemeContextValue, "mode" | "setMode"> {
188
- const { mode, setMode } = useTheme();
189
- return { mode, setMode };
209
+ export function useThemeMode(): Pick<
210
+ ThemeContextValue,
211
+ "mode" | "setMode" | "effectiveMode"
212
+ > {
213
+ const { mode, setMode, effectiveMode } = useTheme();
214
+ return { mode, setMode, effectiveMode };
190
215
  }
package/tsup.config.ts CHANGED
@@ -2,18 +2,18 @@ import type { Options } from "tsup";
2
2
  import { defineConfig } from "tsup";
3
3
 
4
4
  const config: Options = {
5
- entry: ["src/index.ts"],
5
+ entry: ["src/index.ts", "src/plugin.ts"],
6
6
  format: ["cjs", "esm"],
7
7
  dts: true,
8
8
  clean: false, // Tắt clean để tránh nuke mất typography.css khi Next.js đang khởi động
9
9
  // externalize peer dependencies — user project sẽ cung cấp
10
- external: ["react", "react-dom", "motion"],
10
+ external: ["react", "react-dom", "motion", "tailwindcss"],
11
11
  // Tree-shaking tối đa, không split vì thư viện nhỏ
12
12
  treeshake: true,
13
13
  splitting: false,
14
14
  sourcemap: true,
15
15
  outDir: "dist",
16
- // "use client" sẽ được chèn thủ công bằng script phía sau (copy-assets.js)
16
+ // "use client" được inject sau build bằng copy-assets.js (banner của tsup gây esbuild warning cho library)
17
17
  onSuccess: "node scripts/copy-assets.js",
18
18
  };
19
19