This commit is contained in:
shadcn
2026-03-10 16:55:09 +04:00
parent 20a94ddb77
commit dad006aa1e
14 changed files with 155 additions and 69 deletions

View File

@@ -84,8 +84,8 @@ export function MenuAccentPicker({
closeOnClick={isMobile}
disabled={
accent.value === "bold" &&
(params.menuColor === "translucent" ||
params.menuColor === "translucent-inverted")
(params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent")
}
>
{accent.label}

View File

@@ -184,9 +184,10 @@ export function DesignSystemProvider({
}
const isInvertedMenu =
menuColor === "inverted" || menuColor === "translucent-inverted"
menuColor === "inverted" || menuColor === "inverted-translucent"
const isTranslucentMenu =
menuColor === "translucent" || menuColor === "translucent-inverted"
menuColor === "default-translucent" ||
menuColor === "inverted-translucent"
let frameId = 0
const updateMenuElements = () => {

View File

@@ -18,7 +18,10 @@ import {
PickerSeparator,
PickerTrigger,
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
type ColorChoice = "default" | "inverted"
type SurfaceChoice = "solid" | "translucent"
@@ -33,17 +36,17 @@ function getMenuColorValue(
translucent: boolean
): MenuColorValue {
if (color === "default") {
return translucent ? "translucent" : "default"
return translucent ? "default-translucent" : "default"
}
return translucent ? "translucent-inverted" : "inverted"
return translucent ? "inverted-translucent" : "inverted"
}
const MENU_ITEMS: MenuItemConfig[] = [
{ value: "default", label: "Default / Solid" },
{ value: "translucent", label: "Default / Translucent" },
{ value: "default-translucent", label: "Default / Translucent" },
{ value: "inverted", label: "Inverted / Solid" },
{ value: "translucent-inverted", label: "Inverted / Translucent" },
{ value: "inverted-translucent", label: "Inverted / Translucent" },
]
const ALL_OPTIONS = MENU_ITEMS
@@ -57,38 +60,47 @@ export function MenuColorPicker({
const [params, setParams] = useDesignSystemSearchParams()
const { resolvedTheme } = useTheme()
const mounted = useMounted()
const lastSolidMenuAccentRef = React.useRef(params.menuAccent)
const isDark = mounted && resolvedTheme === "dark"
const currentMenu = ALL_OPTIONS.find(
(menu) => menu.value === params.menuColor
)
const colorChoice: ColorChoice =
params.menuColor === "inverted" ||
params.menuColor === "translucent-inverted"
params.menuColor === "inverted-translucent"
? "inverted"
: "default"
const surfaceChoice: SurfaceChoice =
params.menuColor === "translucent" ||
params.menuColor === "translucent-inverted"
params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent"
? "translucent"
: "solid"
React.useEffect(() => {
if (surfaceChoice === "solid") {
lastSolidMenuAccentRef.current = params.menuAccent
}
}, [params.menuAccent, surfaceChoice])
const setColor = (color: ColorChoice) => {
const nextMenuColor = getMenuColorValue(
color,
surfaceChoice === "translucent"
)
setParams({
menuColor: nextMenuColor,
...(surfaceChoice === "translucent" && { menuAccent: "subtle" }),
...(isTranslucentMenuColor(nextMenuColor) && { menuAccent: "subtle" }),
})
}
const setSurface = (choice: SurfaceChoice) => {
const isTranslucent = choice === "translucent"
const nextMenuColor = getMenuColorValue(colorChoice, isTranslucent)
setParams({
menuColor: nextMenuColor,
...(isTranslucent && { menuAccent: "subtle" }),
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
})
}

View File

@@ -18,7 +18,10 @@ import {
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(create)/lib/randomize-biases"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
function randomItem<T>(array: readonly T[]): T {
return array[Math.floor(Math.random() * array.length)]
@@ -68,12 +71,25 @@ export function useRandom() {
const selectedIconLibrary = locks.has("iconLibrary")
? paramsRef.current.iconLibrary
: randomItem(Object.values(iconLibraries)).name
const selectedMenuAccent = locks.has("menuAccent")
const lockedMenuAccent = locks.has("menuAccent")
? paramsRef.current.menuAccent
: randomItem(MENU_ACCENTS).value
: undefined
const availableMenuColors =
!locks.has("menuColor") && lockedMenuAccent === "bold"
? MENU_COLORS.filter((menuColor) => {
return !isTranslucentMenuColor(menuColor.value)
})
: MENU_COLORS
const selectedMenuColor = locks.has("menuColor")
? paramsRef.current.menuColor
: randomItem(MENU_COLORS).value
: randomItem(availableMenuColors).value
const selectedMenuAccent =
locks.has("menuAccent") || isTranslucentMenuColor(selectedMenuColor)
? paramsRef.current.menuAccent === "bold" &&
isTranslucentMenuColor(selectedMenuColor)
? "subtle"
: paramsRef.current.menuAccent
: randomItem(MENU_ACCENTS).value
context.theme = selectedTheme
context.font = selectedFont

View File

@@ -118,6 +118,46 @@ export type DesignSystemSearchParams = inferParserType<
typeof designSystemSearchParams
>
export function isTranslucentMenuColor(
menuColor?: MenuColorValue | null
): menuColor is "default-translucent" | "inverted-translucent" {
return (
menuColor === "default-translucent" || menuColor === "inverted-translucent"
)
}
function normalizePartialDesignSystemParams(
params: Partial<DesignSystemSearchParams>
): Partial<DesignSystemSearchParams> {
if (
params.menuAccent === "bold" &&
isTranslucentMenuColor(params.menuColor ?? undefined)
) {
return {
...params,
menuAccent: "subtle",
}
}
return params
}
function normalizeDesignSystemParams(
params: DesignSystemSearchParams
): DesignSystemSearchParams {
if (
params.menuAccent === "bold" &&
isTranslucentMenuColor(params.menuColor)
) {
return {
...params,
menuAccent: "subtle",
}
}
return params
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
@@ -129,15 +169,13 @@ export function useDesignSystemSearchParams(options: Options = {}) {
})
// If preset param exists, decode it and overlay on raw params.
const params = React.useMemo(() => {
if (rawParams.preset && isPresetCode(rawParams.preset)) {
const decoded = decodePreset(rawParams.preset)
if (decoded) {
return { ...rawParams, ...decoded }
}
}
return rawParams
}, [rawParams])
const params =
rawParams.preset && isPresetCode(rawParams.preset)
? normalizeDesignSystemParams({
...rawParams,
...(decodePreset(rawParams.preset) ?? {}),
})
: normalizeDesignSystemParams(rawParams)
// Use ref so setParams callback stays stable across renders.
const paramsRef = React.useRef(params)
@@ -156,8 +194,9 @@ export function useDesignSystemSearchParams(options: Options = {}) {
) => Partial<DesignSystemSearchParams>),
setOptions?: Options
) => {
const resolvedUpdates =
const resolvedUpdates = normalizePartialDesignSystemParams(
typeof updates === "function" ? updates(paramsRef.current) : updates
)
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
(key) => key in resolvedUpdates
@@ -169,7 +208,10 @@ export function useDesignSystemSearchParams(options: Options = {}) {
}
// Merge current decoded values with updates.
const merged = { ...paramsRef.current, ...resolvedUpdates }
const merged = normalizeDesignSystemParams({
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,

View File

@@ -395,24 +395,24 @@ A `registry:base` item is a complete design system base. It defines the full set
The `config` field accepts the following properties (all optional):
| Property | Type | Description |
| -------------------- | ---------------------------------- | --------------------------------------------------------------- |
| `style` | `string` | The style name for the base. |
| `iconLibrary` | `string` | The icon library to use (e.g. `lucide`). |
| `rsc` | `boolean` | Whether to enable React Server Components. Defaults to `false`. |
| `tsx` | `boolean` | Whether to use TypeScript. Defaults to `true`. |
| `rtl` | `boolean` | Whether to enable right-to-left support. Defaults to `false`. |
| `menuColor` | `"default" \| "inverted"` | The menu color scheme. Defaults to `"default"`. |
| `menuAccent` | `"subtle" \| "bold"` | The menu accent style. Defaults to `"subtle"`. |
| `tailwind.baseColor` | `string` | The base color name (e.g. `neutral`, `slate`, `zinc`). |
| `tailwind.css` | `string` | Path to the Tailwind CSS file. |
| `tailwind.prefix` | `string` | A prefix to add to all Tailwind classes. |
| `aliases.components` | `string` | Import alias for components. |
| `aliases.utils` | `string` | Import alias for utilities. |
| `aliases.ui` | `string` | Import alias for UI components. |
| `aliases.lib` | `string` | Import alias for lib. |
| `aliases.hooks` | `string` | Import alias for hooks. |
| `registries` | `Record<string, string \| object>` | Custom registry URLs. Keys must start with `@`. |
| Property | Type | Description |
| -------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `style` | `string` | The style name for the base. |
| `iconLibrary` | `string` | The icon library to use (e.g. `lucide`). |
| `rsc` | `boolean` | Whether to enable React Server Components. Defaults to `false`. |
| `tsx` | `boolean` | Whether to use TypeScript. Defaults to `true`. |
| `rtl` | `boolean` | Whether to enable right-to-left support. Defaults to `false`. |
| `menuColor` | `"default" \| "inverted" \| "default-translucent" \| "inverted-translucent"` | The menu color scheme. Defaults to `"default"`. |
| `menuAccent` | `"subtle" \| "bold"` | The menu accent style. Defaults to `"subtle"`. |
| `tailwind.baseColor` | `string` | The base color name (e.g. `neutral`, `slate`, `zinc`). |
| `tailwind.css` | `string` | Path to the Tailwind CSS file. |
| `tailwind.prefix` | `string` | A prefix to add to all Tailwind classes. |
| `aliases.components` | `string` | Import alias for components. |
| `aliases.utils` | `string` | Import alias for utilities. |
| `aliases.ui` | `string` | Import alias for UI components. |
| `aliases.lib` | `string` | Import alias for lib. |
| `aliases.hooks` | `string` | Import alias for hooks. |
| `registries` | `Record<string, string \| object>` | Custom registry URLs. Keys must start with `@`. |
```json title="custom-base.json" showLineNumbers
{

View File

@@ -59,7 +59,7 @@
},
"menuColor": {
"type": "string",
"enum": ["default", "inverted"]
"enum": ["default", "inverted", "default-translucent", "inverted-translucent"]
},
"menuAccent": {
"type": "string",

View File

@@ -44,8 +44,8 @@ export type MenuAccentValue = MenuAccent["value"]
export const MENU_COLORS = [
{ value: "default", label: "Default" },
{ value: "inverted", label: "Inverted" },
{ value: "translucent", label: "Translucent" },
{ value: "translucent-inverted", label: "Translucent Inverted" },
{ value: "default-translucent", label: "Default Translucent" },
{ value: "inverted-translucent", label: "Inverted Translucent" },
] as const
export type MenuColor = (typeof MENU_COLORS)[number]

View File

@@ -82,6 +82,16 @@ export const initOptionsSchema = z.object({
existingConfig: z.record(z.unknown()).optional(),
installStyleIndex: z.boolean().default(true),
registryBaseConfig: rawConfigSchema.deepPartial().optional(),
menuColor: z
.enum([
"default",
"inverted",
"default-translucent",
"inverted-translucent",
])
.optional(),
menuAccent: z.enum(["subtle", "bold"]).optional(),
iconLibrary: z.string().optional(),
})
export const init = new Command()

View File

@@ -137,10 +137,10 @@ describe("encodePreset / decodePreset", () => {
}
})
it("should round-trip translucent menu color", () => {
const code = encodePreset({ menuColor: "translucent" })
it("should round-trip default-translucent menu color", () => {
const code = encodePreset({ menuColor: "default-translucent" })
const decoded = decodePreset(code)
expect(decoded!.menuColor).toBe("translucent")
expect(decoded!.menuColor).toBe("default-translucent")
})
})

View File

@@ -93,8 +93,8 @@ export const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
export const PRESET_MENU_COLORS = [
"default",
"inverted",
"translucent",
"translucent-inverted",
"default-translucent",
"inverted-translucent",
] as const
// Field definitions in pack order. Total: 43 bits, 10 bits headroom.

View File

@@ -41,7 +41,12 @@ export const rawConfigSchema = z
iconLibrary: z.string().optional(),
rtl: z.coerce.boolean().default(false).optional(),
menuColor: z
.enum(["default", "inverted", "translucent", "translucent-inverted"])
.enum([
"default",
"inverted",
"default-translucent",
"inverted-translucent",
])
.default("default")
.optional(),
menuAccent: z.enum(["subtle", "bold"]).default("subtle").optional(),
@@ -296,8 +301,8 @@ export const presetSchema = z.object({
menuColor: z.enum([
"default",
"inverted",
"translucent",
"translucent-inverted",
"default-translucent",
"inverted-translucent",
]),
radius: z.string(),
})

View File

@@ -312,7 +312,7 @@ export function Component() {
`)
})
describe("menuColor is translucent", () => {
describe("menuColor is default-translucent", () => {
test("inlines cn-menu-translucent styles", async () => {
expect(
await transform(
@@ -325,7 +325,7 @@ export function Component() {
}`,
config: {
...testConfig,
menuColor: "translucent",
menuColor: "default-translucent",
},
},
[transformMenu]
@@ -351,7 +351,7 @@ export function Component() {
}`,
config: {
...testConfig,
menuColor: "translucent",
menuColor: "default-translucent",
},
},
[transformMenu]
@@ -366,7 +366,7 @@ export function Component() {
})
})
describe("menuColor is translucent-inverted", () => {
describe("menuColor is inverted-translucent", () => {
test("replaces cn-menu-target with dark and inlines cn-menu-translucent", async () => {
expect(
await transform(
@@ -379,7 +379,7 @@ export function Component() {
}`,
config: {
...testConfig,
menuColor: "translucent-inverted",
menuColor: "inverted-translucent",
},
},
[transformMenu]
@@ -405,7 +405,7 @@ export function Component() {
}`,
config: {
...testConfig,
menuColor: "translucent-inverted",
menuColor: "inverted-translucent",
},
},
[transformMenu]

View File

@@ -8,13 +8,13 @@ const TRANSLUCENT_CLASSES =
// Transforms cn-menu-target and cn-menu-translucent classes based on config.menuColor.
// If menuColor is "inverted", replaces cn-menu-target with "dark" and removes cn-menu-translucent.
// If menuColor is "translucent", removes cn-menu-target and inlines cn-menu-translucent styles.
// If menuColor is "translucent-inverted", replaces cn-menu-target with "dark" and inlines cn-menu-translucent styles.
// If menuColor is "default-translucent", removes cn-menu-target and inlines cn-menu-translucent styles.
// If menuColor is "inverted-translucent", replaces cn-menu-target with "dark" and inlines cn-menu-translucent styles.
// Otherwise, removes both cn-menu-target and cn-menu-translucent.
export const transformMenu: Transformer = async ({ sourceFile, config }) => {
const menuColor = config.menuColor
const isTranslucent =
menuColor === "translucent" || menuColor === "translucent-inverted"
menuColor === "default-translucent" || menuColor === "inverted-translucent"
for (const attr of sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
const attrName = attr.getNameNode().getText()
@@ -38,11 +38,11 @@ export const transformMenu: Transformer = async ({ sourceFile, config }) => {
let newText = text
let needsCleanup = false
if (menuColor === "inverted" || menuColor === "translucent-inverted") {
if (menuColor === "inverted" || menuColor === "inverted-translucent") {
// Replace cn-menu-target with "dark".
newText = newText.replace(/cn-menu-target/g, "dark")
} else {
// Remove cn-menu-target for both "translucent" and "default".
// Remove cn-menu-target for both "default-translucent" and "default".
newText = newText.replace(/cn-menu-target/g, "")
needsCleanup = true
}