diff --git a/.changeset/card-ring-overflow-clip.md b/.changeset/card-ring-overflow-clip.md new file mode 100644 index 00000000000..bb490fa39d8 --- /dev/null +++ b/.changeset/card-ring-overflow-clip.md @@ -0,0 +1,8 @@ +--- +"@chakra-ui/react": patch +"@chakra-ui/panda-preset": patch +--- + +Fix issue where the checked ring of `RadioCard` and `CheckboxCard` (outline +variant) gets clipped when a parent has `overflow: hidden|auto|scroll`. The ring +is now drawn with an inset shadow instead of an outer shadow. diff --git a/.changeset/chore-cli-migrate-get-tsconfig.md b/.changeset/chore-cli-migrate-get-tsconfig.md new file mode 100644 index 00000000000..c666640b4b3 --- /dev/null +++ b/.changeset/chore-cli-migrate-get-tsconfig.md @@ -0,0 +1,6 @@ +--- +"@chakra-ui/cli": patch +--- + +Migrate from deprecated `tsconfck` to `get-tsconfig`. Enables installation next +to TypeScript version 6 and up. diff --git a/.changeset/cli-windows-paths.md b/.changeset/cli-windows-paths.md new file mode 100644 index 00000000000..1ae801d0c4c --- /dev/null +++ b/.changeset/cli-windows-paths.md @@ -0,0 +1,5 @@ +--- +"@chakra-ui/cli": patch +--- + +Normalize resolved tsconfig paths to native separators on Windows diff --git a/.changeset/panda-preset-sync.md b/.changeset/panda-preset-sync.md new file mode 100644 index 00000000000..b3da8828a08 --- /dev/null +++ b/.changeset/panda-preset-sync.md @@ -0,0 +1,6 @@ +--- +"@chakra-ui/panda-preset": minor +--- + +Sync theme from `@chakra-ui/react`: register the missing `floatingPanel` slot +recipe and update the date picker range-selection styles diff --git a/apps/www/content/docs/components/concepts/animation.mdx b/apps/www/content/docs/components/concepts/animation.mdx index 5a1d1a9ac50..2b76c8a941c 100644 --- a/apps/www/content/docs/components/concepts/animation.mdx +++ b/apps/www/content/docs/components/concepts/animation.mdx @@ -89,3 +89,54 @@ it easy to create complex animations with multiple keyframes. This is a composed animation ``` + +## Reduced motion + +The built-in component animations play regardless of the user's +`prefers-reduced-motion` setting. To disable the enter/exit animations for users +who prefer reduced motion, add this rule to your `globalCss` config: + +```tsx +import { createSystem, defaultConfig, defineConfig } from "@chakra-ui/react" + +const config = defineConfig({ + globalCss: { + '[data-state="open"], [data-state="closed"]': { + _motionReduce: { + animationDuration: "1ms !important", + }, + }, + }, +}) + +export const system = createSystem(defaultConfig, config) +``` + +This collapses the enter/exit animations of every disclosure component (dialog, +drawer, menu, popover, tooltip, etc.) to `1ms` when reduced motion is enabled. + +A few details worth knowing: + +- **Scope to `data-state="open"` and `data-state="closed"`** rather than + targeting all elements. A blanket rule also collapses looping animations + (`Spinner`, `Skeleton`, indeterminate `Progress`), turning them into a + flicker. Loading indicators are considered essential motion and are best left + running. +- **`animation: none` also works.** The presence logic detects it and unmounts + exiting components immediately. A `1ms` duration is suggested because it keeps + `animationend` events firing for any custom code that listens for them. +- **The `!important` is required** because the rule lives in a lower CSS layer + than component recipes. + +When building custom animations, use the `_motionReduce` condition to provide a +reduced-motion alternative: + +```tsx + + Slides on screen, but only fades for reduced motion users + +``` diff --git a/packages/cli/__tests__/resolve-tsconfig.test.ts b/packages/cli/__tests__/resolve-tsconfig.test.ts index 00af8866cf3..6f7dbfdef30 100644 --- a/packages/cli/__tests__/resolve-tsconfig.test.ts +++ b/packages/cli/__tests__/resolve-tsconfig.test.ts @@ -85,7 +85,7 @@ describe("resolveTsconfig", () => { expect(result).toBe(appTsconfigPath) }) - it("falls back to first reference when no paths found", async () => { + it("picks the reference that includes the source file when no paths exist", async () => { writeFileSync( join(testDir, "tsconfig.json"), JSON.stringify({ @@ -157,7 +157,7 @@ describe("resolveTsconfig", () => { expect(result).toBe(tsconfigPath) }) - it("picks the reference with paths when it is not the first one", async () => { + it("picks the matching reference regardless of reference order", async () => { writeFileSync( join(testDir, "tsconfig.json"), JSON.stringify({ @@ -229,14 +229,14 @@ describe("resolveTsconfig", () => { writeFileSync(join(testDir, "src/index.ts"), "export default {}") const result = await resolveTsconfig(join(testDir, "src/index.ts")) - // tsconfck merges extends into the parsed tsconfig, so paths should be visible + // get-tsconfig merges extends into the parsed tsconfig, so paths should be visible expect(result).toBe(appTsconfigPath) }) it("picks non-first reference with inherited paths over first reference without", async () => { - // This test disambiguates: does tsconfck merge `extends` in referenced - // configs so our `paths` check finds inherited paths? If not, the fallback - // would wrongly pick tsconfig.node.json (listed first). + // The matched config inherits its `paths` via `extends`. parseTsconfig + // resolves the extends chain, so the returned config still exposes the + // inherited paths to downstream consumers (e.g. esbuild). writeFileSync( join(testDir, "tsconfig.json"), JSON.stringify({ @@ -284,6 +284,97 @@ describe("resolveTsconfig", () => { expect(result).toBe(appTsconfigPath) }) + it("falls back to the root config when no reference includes the source file", async () => { + const rootTsconfigPath = join(testDir, "tsconfig.json") + writeFileSync( + rootTsconfigPath, + JSON.stringify({ + files: [], + references: [{ path: "./tsconfig.node.json" }], + }), + ) + + writeFileSync( + join(testDir, "tsconfig.node.json"), + JSON.stringify({ + compilerOptions: {}, + include: ["vite.config.ts"], + }), + ) + + writeFileSync(join(testDir, "src/index.ts"), "export default {}") + + const result = await resolveTsconfig(join(testDir, "src/index.ts")) + expect(result).toBe(rootTsconfigPath) + }) + + it("resolves nested project references", async () => { + writeFileSync( + join(testDir, "tsconfig.json"), + JSON.stringify({ + files: [], + references: [{ path: "./tsconfig.solution.json" }], + }), + ) + + // intermediate solution-style config referencing the app config + writeFileSync( + join(testDir, "tsconfig.solution.json"), + JSON.stringify({ + files: [], + references: [{ path: "./tsconfig.app.json" }], + }), + ) + + const appTsconfigPath = join(testDir, "tsconfig.app.json") + writeFileSync( + appTsconfigPath, + JSON.stringify({ + compilerOptions: { + paths: { "@/*": ["./src/*"] }, + }, + include: ["src"], + }), + ) + + writeFileSync(join(testDir, "src/index.ts"), "export default {}") + + const result = await resolveTsconfig(join(testDir, "src/index.ts")) + expect(result).toBe(appTsconfigPath) + }) + + it("tolerates circular and missing references", async () => { + writeFileSync( + join(testDir, "tsconfig.json"), + JSON.stringify({ + files: [], + references: [ + // circular: points back to the root config + { path: "./tsconfig.json" }, + // missing: file does not exist + { path: "./tsconfig.missing.json" }, + { path: "./tsconfig.app.json" }, + ], + }), + ) + + const appTsconfigPath = join(testDir, "tsconfig.app.json") + writeFileSync( + appTsconfigPath, + JSON.stringify({ + compilerOptions: { + paths: { "@/*": ["./src/*"] }, + }, + include: ["src"], + }), + ) + + writeFileSync(join(testDir, "src/index.ts"), "export default {}") + + const result = await resolveTsconfig(join(testDir, "src/index.ts")) + expect(result).toBe(appTsconfigPath) + }) + it("finds tsconfig from deeply nested source file", async () => { const tsconfigPath = join(testDir, "tsconfig.json") writeFileSync( diff --git a/packages/cli/package.json b/packages/cli/package.json index 1b194a4ad47..e5be0fcd989 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,6 +61,7 @@ "debug": "^4.4.3", "dotenv": "^17.2.3", "esbuild": "^0.27.3", + "get-tsconfig": "^4.14.0", "globby": "16.1.1", "https-proxy-agent": "^7.0.6", "look-it-up": "2.1.0", @@ -69,7 +70,6 @@ "prettier": "3.8.1", "recast": "^0.23.0", "scule": "1.3.0", - "tsconfck": "^3.1.6", "zod": "^3.25.76" }, "peerDependencies": { diff --git a/packages/cli/src/utils/resolve-tsconfig.ts b/packages/cli/src/utils/resolve-tsconfig.ts index ffe6b46c5f6..4490f25c143 100644 --- a/packages/cli/src/utils/resolve-tsconfig.ts +++ b/packages/cli/src/utils/resolve-tsconfig.ts @@ -1,9 +1,112 @@ import createDebug from "debug" -import { resolve } from "node:path" -import { parse } from "tsconfck" +import { createFilesMatcher, getTsconfig, parseTsconfig } from "get-tsconfig" +import type { TsConfigJsonResolved } from "get-tsconfig" +import { realpathSync, statSync } from "node:fs" +import { dirname, join, normalize, resolve } from "node:path" const debug = createDebug("chakra:tsconfig") +type ConfigNode = { + path: string + config: TsConfigJsonResolved + references?: ConfigNode[] +} + +// resolve relative path to referenced tsconfig +function resolveReferencePath(configPath: string, refPath: string): string { + const base = resolve(dirname(configPath), refPath) + + try { + if (statSync(base).isFile()) return base + } catch {} + + return join(base, "tsconfig.json") +} + +// recursively load referenced tsconfigs +function loadReference( + parent: ConfigNode, + refPath: string, + seen: Set, +): ConfigNode { + const configPath = resolveReferencePath(parent.path, refPath) + + const real = realpathSync(configPath) + if (seen.has(real)) { + throw new Error(`Circular tsconfig reference: ${configPath}`) + } + + const config = parseTsconfig(configPath) + const node: ConfigNode = { + path: configPath, + config, + } + + // follow nested references + seen.add(real) + resolveReferences(node, seen) + seen.delete(real) + + return node +} + +// recursively resolve referenced tsconfigs +function resolveReferences( + node: ConfigNode, + seen = new Set(), +): ConfigNode { + const { references } = node.config + if (!references?.length) return node + + debug("solution-style tsconfig detected, checking references...") + node.references = references.flatMap((ref) => { + // Skip references that are missing, malformed, or circular so a single + // broken entry doesn't fail resolution for the whole project + if (!ref?.path) { + debug("invalid tsconfig reference in", node.path) + return [] + } + try { + return [loadReference(node, ref.path, seen)] + } catch (error) { + debug("skipping unresolvable reference:", ref.path, error) + return [] + } + }) + + return node +} + +function findConfigForFile(root: ConfigNode, sourceFile: string): ConfigNode { + function visit(node: ConfigNode): ConfigNode | undefined { + // Explore children first so the first matching descendant wins. + for (const ref of node.references ?? []) { + const match = visit(ref) + if (match) return match + } + + const matcher = createFilesMatcher(node) + return matcher(sourceFile) ? node : undefined + } + + return visit(root) ?? root +} + +function loadResolvedConfig(sourceFile: string): ConfigNode | undefined { + const root = getTsconfig(sourceFile) + if (!root) return undefined + + const real = realpathSync(root.path) + const node: ConfigNode = { + path: root.path, + config: root.config, + } + + const seen = new Set([real]) + const resolved = resolveReferences(node, seen) + return findConfigForFile(resolved, sourceFile) +} + /** * Resolves the correct tsconfig file for a given source file. * @@ -22,33 +125,18 @@ export async function resolveTsconfig( } try { - const result = await parse(sourceFile) + const nearest = loadResolvedConfig(sourceFile) - if (!result.tsconfigFile) { + if (!nearest) { debug("no tsconfig found for", sourceFile) return undefined } - debug("found tsconfig:", result.tsconfigFile) - - // If this is a solution-style tsconfig (has references, no real files), - // find the referenced config that contains `paths` - if (result.referenced && result.referenced.length > 0) { - debug("solution-style tsconfig detected, checking references...") - - for (const ref of result.referenced) { - if (ref.tsconfig?.compilerOptions?.paths) { - debug("found paths in referenced tsconfig:", ref.tsconfigFile) - return ref.tsconfigFile - } - } - - // Fall back to the first referenced tsconfig - debug("no paths found in references, using first reference") - return result.referenced[0].tsconfigFile - } - - return result.tsconfigFile + // get-tsconfig returns forward-slash paths while reference resolution + // produces native paths. Normalize so output is native on every platform. + const configPath = normalize(nearest.path) + debug("found tsconfig:", configPath) + return configPath } catch (error) { debug("tsconfig resolution failed:", error) return undefined diff --git a/packages/panda-preset/scripts/sync.ts b/packages/panda-preset/scripts/sync.ts index ac4e80c7ef6..cc1df37bf9c 100644 --- a/packages/panda-preset/scripts/sync.ts +++ b/packages/panda-preset/scripts/sync.ts @@ -1,6 +1,6 @@ import { globby } from "globby" import { readFile, rm, writeFile } from "node:fs/promises" -import { dirname, join, normalize, relative, resolve } from "node:path" +import { dirname, join, normalize, relative, resolve, sep } from "node:path" import { format } from "prettier" import { cleanFiles } from "./shared" @@ -56,7 +56,8 @@ async function main() { const promises = files.map(async (file) => { const content = await readFile(file, "utf8") - let relativePath = relative(dirname(file), defFile) + // generated import specifiers must use posix separators + let relativePath = relative(dirname(file), defFile).split(sep).join("/") relativePath = relativePath === "def.ts" ? "./def.ts" : relativePath const fileFromSrc = relative("src", file) diff --git a/packages/panda-preset/src/slot-recipes/checkbox-card.ts b/packages/panda-preset/src/slot-recipes/checkbox-card.ts index adab9bd0d4e..cdcbb295954 100644 --- a/packages/panda-preset/src/slot-recipes/checkbox-card.ts +++ b/packages/panda-preset/src/slot-recipes/checkbox-card.ts @@ -190,7 +190,7 @@ export const checkboxCardSlotRecipe = defineSlotRecipe({ borderWidth: "1px", borderColor: "border", _checked: { - boxShadow: "0 0 0 1px var(--shadow-color)", + boxShadow: "inset 0 0 0 1px var(--shadow-color)", boxShadowColor: "colorPalette.solid", borderColor: "colorPalette.solid", }, diff --git a/packages/panda-preset/src/slot-recipes/date-picker.ts b/packages/panda-preset/src/slot-recipes/date-picker.ts index 2ef242deae1..2aff308aec6 100644 --- a/packages/panda-preset/src/slot-recipes/date-picker.ts +++ b/packages/panda-preset/src/slot-recipes/date-picker.ts @@ -247,7 +247,7 @@ export const datePickerSlotRecipe = defineSlotRecipe({ textUnderlineOffset: "3px", textDecorationThickness: "2px", }, - _selected: { + "&[data-selected]": { bg: "colorPalette.solid", color: "colorPalette.contrast", _hover: { @@ -262,33 +262,23 @@ export const datePickerSlotRecipe = defineSlotRecipe({ bg: "colorPalette.subtle", }, }, - "&[data-range-start]": { + "&[data-in-range][data-selected]": { bg: "colorPalette.solid", color: "colorPalette.contrast", borderRadius: "0", - borderStartRadius: "l2", _hover: { bg: "colorPalette.solid", }, - }, - "&[data-range-end]": { - bg: "colorPalette.solid", - color: "colorPalette.contrast", - borderRadius: "0", - borderEndRadius: "l2", - _hover: { - bg: "colorPalette.solid", + "&[data-range-start][data-range-end]": { + borderRadius: "l2", }, - }, - "&[data-range-start][data-range-end]": { - borderRadius: "l2", - }, - "&[data-selected][data-in-range]": { - bg: "colorPalette.solid", - color: "colorPalette.contrast", - borderRadius: "l2", - _hover: { - bg: "colorPalette.solid", + "&[data-range-start]:not([data-range-end])": { + borderStartRadius: "l2", + borderEndRadius: "0", + }, + "&[data-range-end]:not([data-range-start])": { + borderEndRadius: "l2", + borderStartRadius: "0", }, }, _disabled: { diff --git a/packages/panda-preset/src/slot-recipes/index.ts b/packages/panda-preset/src/slot-recipes/index.ts index d3e49e9efdb..4702bc97716 100644 --- a/packages/panda-preset/src/slot-recipes/index.ts +++ b/packages/panda-preset/src/slot-recipes/index.ts @@ -21,6 +21,7 @@ import { emptyStateSlotRecipe } from "./empty-state" import { fieldSlotRecipe } from "./field" import { fieldsetSlotRecipe } from "./fieldset" import { fileUploadSlotRecipe } from "./file-upload" +import { floatingPanelSlotRecipe } from "./floating-panel" import { hoverCardSlotRecipe } from "./hover-card" import { listSlotRecipe } from "./list" import { listboxSlotRecipe } from "./listbox" @@ -110,4 +111,5 @@ export const slotRecipes = { qrCode: qrCodeSlotRecipe, treeView: treeViewSlotRecipe, marquee: marqueeSlotRecipe, + floatingPanel: floatingPanelSlotRecipe, } diff --git a/packages/panda-preset/src/slot-recipes/radio-card.ts b/packages/panda-preset/src/slot-recipes/radio-card.ts index d5b1dd806e5..3071777dd99 100644 --- a/packages/panda-preset/src/slot-recipes/radio-card.ts +++ b/packages/panda-preset/src/slot-recipes/radio-card.ts @@ -212,7 +212,7 @@ export const radioCardSlotRecipe = defineSlotRecipe({ item: { borderWidth: "1px", _checked: { - boxShadow: "0 0 0 1px var(--shadow-color)", + boxShadow: "inset 0 0 0 1px var(--shadow-color)", boxShadowColor: "colorPalette.solid", borderColor: "colorPalette.solid", }, diff --git a/packages/react/src/theme/recipes/checkbox-card.ts b/packages/react/src/theme/recipes/checkbox-card.ts index c581535acf0..fadb3c05301 100644 --- a/packages/react/src/theme/recipes/checkbox-card.ts +++ b/packages/react/src/theme/recipes/checkbox-card.ts @@ -147,7 +147,7 @@ export const checkboxCardSlotRecipe = defineSlotRecipe({ borderWidth: "1px", borderColor: "border", _checked: { - boxShadow: "0 0 0 1px var(--shadow-color)", + boxShadow: "inset 0 0 0 1px var(--shadow-color)", boxShadowColor: "colorPalette.solid", borderColor: "colorPalette.solid", }, diff --git a/packages/react/src/theme/recipes/radio-card.ts b/packages/react/src/theme/recipes/radio-card.ts index 64e8de578ed..6f5c20eddf9 100644 --- a/packages/react/src/theme/recipes/radio-card.ts +++ b/packages/react/src/theme/recipes/radio-card.ts @@ -152,7 +152,7 @@ export const radioCardSlotRecipe = defineSlotRecipe({ item: { borderWidth: "1px", _checked: { - boxShadow: "0 0 0 1px var(--shadow-color)", + boxShadow: "inset 0 0 0 1px var(--shadow-color)", boxShadowColor: "colorPalette.solid", borderColor: "colorPalette.solid", }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f1192d3e28..cf73d5df8f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -598,6 +598,9 @@ importers: esbuild: specifier: ^0.27.3 version: 0.27.3 + get-tsconfig: + specifier: ^4.14.0 + version: 4.14.0 globby: specifier: 16.1.1 version: 16.1.1 @@ -622,9 +625,6 @@ importers: scule: specifier: 1.3.0 version: 1.3.0 - tsconfck: - specifier: ^3.1.6 - version: 3.1.6(typescript@5.9.3) zod: specifier: ^3.25.76 version: 3.25.76 diff --git a/scripts/build/build.ts b/scripts/build/build.ts index 96e5006eb67..2f9058f425f 100644 --- a/scripts/build/build.ts +++ b/scripts/build/build.ts @@ -1,6 +1,6 @@ import { Alias } from "@rollup/plugin-alias" import { rmSync } from "fs" -import { join } from "path/posix" +import { join } from "node:path" import * as rollup from "rollup" import { getConfig } from "./config.js" import { generateTypes } from "./tsc.js" diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 5b58fa0a30a..4dde6fefd56 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -7,6 +7,7 @@ import { resolve } from "node:path" import { Plugin, RollupOptions } from "rollup" import esbuild from "rollup-plugin-esbuild" import { preserveDirectives } from "rollup-plugin-preserve-directives" +import { readPackageJson } from "./read-package-json.js" interface Options { dir: string @@ -16,7 +17,7 @@ interface Options { export async function getConfig(options: Options): Promise { const { dir, aliases } = options - const packageJson = await import(resolve(dir, "package.json")) + const packageJson = readPackageJson(dir) const isCli = packageJson.bin !== undefined || packageJson.name.includes("docgen") diff --git a/scripts/build/main.ts b/scripts/build/main.ts index 1d1b331767d..b91e75edf17 100644 --- a/scripts/build/main.ts +++ b/scripts/build/main.ts @@ -1,5 +1,5 @@ -import { resolve } from "path/posix" import { buildProject } from "./build.js" +import { readPackageJson } from "./read-package-json.js" async function main() { const cwd = process.cwd() @@ -8,7 +8,7 @@ async function main() { const clean = flags.includes("--clean") const dts = flags.includes("--dts") - const packageJson = await import(resolve(cwd, "package.json")) + const packageJson = readPackageJson(cwd) await buildProject({ dir: cwd, diff --git a/scripts/build/read-package-json.ts b/scripts/build/read-package-json.ts new file mode 100644 index 00000000000..cadea336992 --- /dev/null +++ b/scripts/build/read-package-json.ts @@ -0,0 +1,6 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" + +export function readPackageJson(dir: string) { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) +} diff --git a/scripts/build/tsc.ts b/scripts/build/tsc.ts index 5cc45c55fe0..c592243ce2d 100644 --- a/scripts/build/tsc.ts +++ b/scripts/build/tsc.ts @@ -1,5 +1,5 @@ import { cpSync } from "node:fs" -import { join } from "node:path/posix" +import { join } from "node:path" export async function generateTypes(dir: string) { const { execa } = await import("execa")