diff --git a/docs/STYLING.md b/docs/STYLING.md index fa606b53..25886bd1 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -203,6 +203,11 @@ export type ButtonRecipeVariants = RecipeSelection; `RecipeSelection` contains only variant keys. `className` is composition input, not a variant. +The variant groups in `variants` are the only groups a recipe accepts. A group name that `variants` +does not declare is a type error in `defaultVariants`, `compoundVariants`, `compoundSlots`, and +`withDefaultVariants` defaults, including in a config declared before the `recipe()` call and in a +compound entry a helper function returns. + ### Shared input-state selectors Field-control recipes share hover, focus, disabled, invalid, and read-only selectors from diff --git a/packages/@luke-ui/react/src/core/styles/recipe.test.ts b/packages/@luke-ui/react/src/core/styles/recipe.test.ts new file mode 100644 index 00000000..1608ceab --- /dev/null +++ b/packages/@luke-ui/react/src/core/styles/recipe.test.ts @@ -0,0 +1,142 @@ +import { assertType, describe, expectTypeOf, test } from 'vite-plus/test'; +import type { RecipeSelection } from './recipe-types.js'; +import type { SlottedConfigInput } from './recipe.js'; +import { recipe, withDefaultVariants } from './recipe.js'; + +// `recipe()` needs a Vanilla Extract file scope, so every build sits in an arrow that only the +// type checker reads. A config declared before the call matters most, because excess property +// checks skip it. + +// Single-part `compoundVariants` is a mutable array, so `as const` goes on each entry's parts. +const singlePart = { + compoundVariants: [{ style: {}, variants: { size: 'small', tone: 'accent' } as const }], + defaultVariants: { size: 'medium' } as const, + variants: { + size: { medium: {}, small: {} }, + tone: { accent: {} }, + } as const, +}; + +const slotted = { + compoundSlots: [{ slots: ['root'], style: {}, variants: { size: 'small' } }], + defaultVariants: { size: 'medium' }, + slots: { label: {}, root: {} }, + variants: { size: { medium: { root: {} }, small: { label: {} } } }, +} as const satisfies SlottedConfigInput; + +describe('recipe variant keys', () => { + test('infers known groups and values from the definition', () => { + const buildSinglePart = () => recipe(singlePart); + const buildSlotted = () => recipe(slotted); + + expectTypeOf>>().toEqualTypeOf<{ + size?: 'medium' | 'small' | undefined; + tone?: 'accent' | undefined; + }>(); + expectTypeOf>>().toEqualTypeOf<{ + size?: 'medium' | 'small' | undefined; + }>(); + }); + + test('rejects an unknown group in a predeclared single-part config', () => { + const unknownDefault = { + defaultVariants: { bogus: 'x', size: 'small' }, + variants: { size: { small: {} } }, + } as const; + const unknownCompound = { + compoundVariants: [ + { style: {}, variants: { size: 'small' } as const }, + { style: {}, variants: { bogus: 'x', size: 'small' } as const }, + ], + variants: { size: { small: {} } } as const, + }; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownDefault), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownCompound), + ); + }); + + test('rejects an unknown group in a compound variant built by a helper', () => { + const entry = () => ({ style: {}, variants: { bogus: 'x' as const, size: 'small' as const } }); + + assertType(() => + recipe({ + // @ts-expect-error — `bogus` is not a variant group + compoundVariants: [entry()], + variants: { size: { small: {} } }, + }), + ); + }); + + test('rejects an unknown group in a predeclared slotted config', () => { + const unknownDefault = { + defaultVariants: { bogus: 'x', size: 'small' }, + slots: { root: {} }, + variants: { size: { small: { root: {} } } }, + } as const satisfies SlottedConfigInput; + const unknownCompound = { + compoundSlots: [{ slots: ['root'], style: {}, variants: { bogus: 'x', size: 'small' } }], + slots: { root: {} }, + variants: { size: { small: { root: {} } } }, + } as const satisfies SlottedConfigInput; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownDefault), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownCompound), + ); + }); + + test('rejects any group on a recipe without variants', () => { + const singlePartDefault = { base: {}, defaultVariants: { bogus: 'x' } } as const; + const slottedDefault = { + defaultVariants: { bogus: 'x' }, + slots: { root: {} }, + } as const satisfies SlottedConfigInput; + + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe(singlePartDefault), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe(slottedDefault), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe({ base: {} })({ bogus: 'x' }), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe({ slots: { root: {} } })({ bogus: 'x' }), + ); + }); + + test('rejects an unknown group in a selection', () => { + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(singlePart)({ bogus: 'x', size: 'small' }), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(slotted)({ bogus: 'x', size: 'small' }), + ); + }); + + test('rejects an unknown key in predeclared withDefaultVariants defaults', () => { + const defaults = { bogus: 'x', size: 'small' } as const; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + withDefaultVariants(recipe(singlePart), defaults), + ); + }); +}); diff --git a/packages/@luke-ui/react/src/core/styles/recipe.ts b/packages/@luke-ui/react/src/core/styles/recipe.ts index c5975dc0..791e37e2 100644 --- a/packages/@luke-ui/react/src/core/styles/recipe.ts +++ b/packages/@luke-ui/react/src/core/styles/recipe.ts @@ -67,6 +67,35 @@ type Selection = -readonly [Group in keyof Variants]?: BooleanMap | undefined; }; +/** Keys of every member of a union, not only the keys every member shares. */ +type KeysOfUnion = T extends unknown ? keyof T : never; + +/** + * Marks each key of `Actual` that `Allowed` does not declare as `never`. + * + * Excess property checks run only on fresh object literals. A config declared before the + * `recipe()` call (the `as const satisfies SlottedConfigInput` pattern) or a compound entry a + * helper function returns is not a fresh literal at the call site, so it would otherwise name any + * group with no error. + */ +type RejectUnknownKeys = { + [Key in Exclude, keyof Allowed>]?: never; +}; + +/** A selection that rejects every group `Actual` names that the recipe does not declare. */ +type StrictSelection = Selection & + RejectUnknownKeys>; + +/** + * The type a config passes for an authored selection: the captured `Actual`, checked against the + * recipe's groups. `NoInfer` keeps `config.variants` as the only inference source for `Variants`. + */ +type CheckedSelection = Actual & NoInfer>; + +/** The union of every `variants` selection in a list of compound entries. */ +type CompoundSelections> = + Compounds[number]['variants']; + /** Variant selection plus optional `className`. */ type RecipeInput = HasNoGroups extends true @@ -75,17 +104,28 @@ type RecipeInput = -readonly [Group in keyof Variants]?: BooleanMap | undefined; } & RecipeComposition; -/** A compound variant for a single-part recipe. */ -interface CompoundVariant { +/** + * A compound variant for a single-part recipe. `Selected` is the union of every compound + * selection in the config, so an unknown group in any entry is rejected. + */ +interface CompoundVariant> { style: RecipeStyleRule; - variants: Selection; + variants: StrictSelection; } -/** Single-part recipe config. */ -interface SinglePartConfig { +/** + * Single-part recipe config. `Defaults` and `Compounds` feed `RejectUnknownKeys` the authored + * selections. + */ +interface SinglePartConfig< + Variants extends VariantGroups, + Defaults = Selection, + Compounds extends Array> = Array>, +> { base?: RecipeStyleRule; - compoundVariants?: Array>; - defaultVariants?: Selection; + compoundVariants?: Compounds & + NoInfer>>>; + defaultVariants?: CheckedSelection; variants?: Variants; } @@ -103,16 +143,29 @@ type SlotVariantGroups = Record> { +interface CompoundSlot< + Slot extends string, + Variants extends SlotVariantGroups, + Selected = Selection, +> { slots: ReadonlyArray>; style: SlottedStyleRule; - variants?: NoInfer>; + variants?: NoInfer>; } -/** Slotted recipe config. */ -interface MultiPartConfig> { - compoundSlots?: Array>; - defaultVariants?: Selection; +/** + * Slotted recipe config. `Defaults` and `Compounds` feed `RejectUnknownKeys` the authored + * selections. + */ +interface MultiPartConfig< + Slot extends string, + Variants extends SlotVariantGroups, + Defaults = Selection, + Compounds extends Array> = Array>, +> { + compoundSlots?: Compounds & + NoInfer>>>; + defaultVariants?: CheckedSelection; slots: Record; variants?: Variants; } @@ -148,14 +201,19 @@ export interface SlottedConfigInput { // --------------------------------------------------------------------------- /** Builds a slotted recipe (variant selection at the outer call, one function per slot). */ -export function recipe>( - config: MultiPartConfig, -): MultiPartRecipe; +export function recipe< + const Slot extends string, + const Variants extends SlotVariantGroups, + const Defaults = Selection, + const Compounds extends Array> = Array>, +>(config: MultiPartConfig): MultiPartRecipe; /** Builds a single-part recipe (variant selection at the outer call, returns a class string). */ -export function recipe( - config: SinglePartConfig, -): SinglePartRecipe; +export function recipe< + const Variants extends VariantGroups, + const Defaults = Selection, + const Compounds extends Array> = Array>, +>(config: SinglePartConfig): SinglePartRecipe; export function recipe(config: AnyMultiPartConfig | SinglePartConfig): unknown { if (isMultiPart(config)) { @@ -192,11 +250,20 @@ function registerSerializer(fn: object, importName: string, args: ReadonlyArray< }); } -/** Adds defaults to a recipe without changing consumer-supplied variant selections. */ -export function withDefaultVariants( +/** + * Adds defaults to a recipe without changing consumer-supplied variant selections. `Defaults` + * feeds `RejectUnknownKeys` the authored defaults. + */ +export function withDefaultVariants< + Input extends object, + PublicInput extends Input = Input, + const Defaults extends Input = Input, +>( recipe: (input?: Input) => string, - defaults: Input, + checkedDefaults: Defaults & RejectUnknownKeys, ): (input?: PublicInput) => string { + // The rejection map only exists to fail the call; past it, the defaults are an `Input`. + const defaults: Input = checkedDefaults; const wrapped = (input?: PublicInput) => { const selection = { ...defaults, ...input }; for (const key in defaults) {