Skip to content

Commit 918d89e

Browse files
timphandevclaude
andcommitted
fix(class-recipe): accept its own slots in a function generic over the slot recipe it composes
As in @lynstack/recipe, a function generic over the slot recipe that its sva recipe composes could not name the recipe's own slots in `base`, the variants, or the compound variants, since their keys were typed with ComposedSlot. The config now types the slots of the composed recipes apart and checks the recipe's own slots first. SlotClasses is now one mapped type, the same type as before, which declarations print by name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1d825ff commit 918d89e

13 files changed

Lines changed: 197 additions & 37 deletions

File tree

‎api/class-recipe.d.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Composable, ComposableKindRecipe, ComposableKindRecipe as ComposableKindRecipe$1, ComposableKindSlotRecipe, ComposableKindSlotRecipe as ComposableKindSlotRecipe$1, ComposedDefaultedName, ComposedSlot, ComposedSlot as ComposedSlot$1, ComposedVariants, ComposedVariants as ComposedVariants$1, CompoundCondition, DefaultVariants, KindRecipe, KindVariants, RecipeComposition, RecipeComposition as RecipeComposition$1, RecipeFunction, VariantKey, VariantOption, VariantSelection, VariantSelection as VariantSelection$1, VariantsOf as VariantsOf$1 } from "@lynstack/recipe";
1+
import { Composable, ComposableKindRecipe, ComposableKindRecipe as ComposableKindRecipe$1, ComposableKindSlotRecipe, ComposableKindSlotRecipe as ComposableKindSlotRecipe$1, ComposedDefaultedName, ComposedSlot, ComposedVariants, ComposedVariants as ComposedVariants$1, CompoundCondition, DefaultVariants, KindRecipe, KindVariants, RecipeComposition, RecipeComposition as RecipeComposition$1, RecipeFunction, VariantKey, VariantOption, VariantSelection, VariantSelection as VariantSelection$1, VariantsOf as VariantsOf$1 } from "@lynstack/recipe";
22
//#region src/cx.d.ts
33
type ClassValue = ClassArray | ClassDictionary | string | number | boolean | null | undefined;
44
type ClassDictionary = Readonly<Record<string, unknown>>;
@@ -22,9 +22,10 @@ type VariantDefaults<Props> = KindRecipeOf<Props>["defaultVariants"];
2222
type VariantsOf<Recipe extends (props: never) => unknown> = OptionsOnly<Omit<VariantsOf$1<Recipe>, OverrideName>>;
2323
type PropsOf<Recipe extends (props: never) => unknown> = Simplify<NonNullable<Parameters<Recipe>[0]>>;
2424
type OptionsOnly<Variants> = { [Name in keyof Variants]: Exclude<Variants[Name], object>; };
25-
type SlotClasses<Slot extends string> = Readonly<Partial<Record<Slot, string | undefined>>>;
25+
type SlotClasses<Slot extends string> = { readonly [Name in Slot]?: string | undefined; };
2626
type SlotClassNames<Slot extends string> = Readonly<Record<Slot, string>>;
27-
type NoUnknownSlots<Variants, Slot extends string> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: string extends keyof Variants[Name][Option] ? unknown : Readonly<Record<Exclude<keyof Variants[Name][Option], Slot>, never>>; }; };
27+
type NoUnknownSlots<Variants, Slot extends string, Inherited extends string = never> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: string extends keyof Variants[Name][Option] ? unknown : Readonly<Record<Exclude<Exclude<keyof Variants[Name][Option], Slot>, Inherited>, never>>; }; };
28+
type InheritedSlot<Composed extends readonly ComposableKindSlotRecipe$1<string>[]> = NonNullable<Composed[number]["~composition"]>["slots"][number];
2829
type WideSelection<Slot extends string> = Readonly<Record<string, string | SlotClasses<Slot> | undefined>>;
2930
type SlotRecipeProps<Slot extends string, Variants, DefaultedName extends keyof Variants> = Simplify<(string extends keyof Variants ? WideSelection<Slot> : VariantSelection$1<Variants, DefaultedName>) & {
3031
readonly classNames?: SlotClasses<Slot> | undefined;
@@ -69,12 +70,12 @@ interface SlotCompoundVariant<Slot extends string, Variants> {
6970
interface SlotRecipeConfig<Slot extends string, Variants extends SlotRecipeVariants, DefaultedName extends keyof ComposedVariants$1<Composed, Variants>, Composed extends readonly ComposableKindSlotRecipe$1<string>[] = readonly []> {
7071
readonly composes?: Composed | undefined;
7172
readonly slots: readonly Slot[];
72-
readonly base?: SlotClasses<NoInfer<ComposedSlot$1<Composed, Slot>>> | undefined;
73-
readonly variants: Variants & NoUnknownSlots<Variants, NoInfer<ComposedSlot$1<Composed, Slot>>> & {
73+
readonly base?: SlotClasses<NoInfer<Slot> | InheritedSlot<Composed>> | undefined;
74+
readonly variants: Variants & NoUnknownSlots<Variants, NoInfer<Slot>, InheritedSlot<Composed>> & {
7475
readonly className?: never;
7576
readonly classNames?: never;
7677
};
77-
readonly compoundVariants?: readonly SlotCompoundVariant<NoInfer<ComposedSlot$1<Composed, Slot>>, NoInfer<ComposedVariants$1<Composed, Variants>>>[] | undefined;
78+
readonly compoundVariants?: readonly SlotCompoundVariant<NoInfer<Slot> | InheritedSlot<Composed>, NoInfer<ComposedVariants$1<Composed, Variants>>>[] | undefined;
7879
readonly defaultVariants?: DefaultVariants<ComposedVariants$1<Composed, Variants>, DefaultedName> | undefined;
7980
readonly cache?: boolean | undefined;
8081
}

‎api/consumers/class-recipe/chain.d.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
declare const level0: NoInfer<((props: {
2-
readonly classNames?: Readonly<Partial<Record<"root", string | undefined>>> | undefined;
2+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"root"> | undefined;
33
readonly size: import("@lynstack/class-recipe").VariantOption<{
44
readonly md: {
55
readonly root: "h-8";
@@ -34,7 +34,7 @@ declare const level0: NoInfer<((props: {
3434
}, never, string, readonly "root"[]> | undefined;
3535
}>;
3636
declare const level1: NoInfer<((props: {
37-
readonly classNames?: Readonly<Partial<Record<"icon" | "root", string | undefined>>> | undefined;
37+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"icon" | "root"> | undefined;
3838
readonly size: import("@lynstack/class-recipe").VariantOption<{
3939
readonly md: {
4040
readonly root: "h-8";
@@ -76,7 +76,7 @@ declare const level1: NoInfer<((props: {
7676
}, never, string, readonly ("icon" | "root")[]> | undefined;
7777
}>;
7878
declare const level2: NoInfer<((props: {
79-
readonly classNames?: Readonly<Partial<Record<"icon" | "label" | "root", string | undefined>>> | undefined;
79+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"icon" | "label" | "root"> | undefined;
8080
readonly size: import("@lynstack/class-recipe").VariantOption<{
8181
readonly md: {
8282
readonly root: "h-8";
@@ -125,7 +125,7 @@ declare const level2: NoInfer<((props: {
125125
}, never, string, readonly ("icon" | "label" | "root")[]> | undefined;
126126
}>;
127127
declare const level3: NoInfer<((props: {
128-
readonly classNames?: Readonly<Partial<Record<"badge" | "icon" | "label" | "root", string | undefined>>> | undefined;
128+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"badge" | "icon" | "label" | "root"> | undefined;
129129
readonly shape: "round";
130130
readonly size: import("@lynstack/class-recipe").VariantOption<{
131131
readonly md: {
@@ -181,7 +181,7 @@ declare const level3: NoInfer<((props: {
181181
}, never, string, readonly ("badge" | "icon" | "label" | "root")[]> | undefined;
182182
}>;
183183
declare const level4: NoInfer<((props: {
184-
readonly classNames?: Readonly<Partial<Record<"badge" | "hint" | "icon" | "label" | "root", string | undefined>>> | undefined;
184+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"badge" | "hint" | "icon" | "label" | "root"> | undefined;
185185
readonly muted?: import("@lynstack/class-recipe").VariantOption<{
186186
readonly true: {
187187
readonly hint: "opacity-50";

‎api/consumers/class-recipe/configured-recipes.d.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ declare const tag: ((props?: {
120120
}, "size" | "tone", string, undefined> | undefined;
121121
};
122122
declare const look: NoInfer<((props: {
123-
readonly classNames?: Readonly<Partial<Record<"root", string | undefined>>> | undefined;
123+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"root"> | undefined;
124124
readonly size: import("@lynstack/class-recipe").VariantOption<{
125125
readonly md: {
126126
readonly root: "h-8";
@@ -155,7 +155,7 @@ declare const look: NoInfer<((props: {
155155
}, never, string, readonly "root"[]> | undefined;
156156
}>;
157157
declare const field: NoInfer<((props: {
158-
readonly classNames?: Readonly<Partial<Record<"label" | "root", string | undefined>>> | undefined;
158+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"label" | "root"> | undefined;
159159
readonly invalid?: import("@lynstack/class-recipe").VariantOption<{
160160
readonly true: {
161161
readonly label: "text-red-700";
@@ -205,7 +205,7 @@ declare const field: NoInfer<((props: {
205205
}, never, string, readonly ("label" | "root")[]> | undefined;
206206
}>;
207207
declare const select: NoInfer<((props?: {
208-
readonly classNames?: Readonly<Partial<Record<"label" | "root" | "trigger", string | undefined>>> | undefined;
208+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"label" | "root" | "trigger"> | undefined;
209209
readonly invalid?: import("@lynstack/class-recipe").VariantOption<{
210210
readonly true: {
211211
readonly label: "text-red-700";

‎api/consumers/class-recipe/define.d.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,12 @@ declare function defineComposed<const Variants extends RecipeVariants, const Def
88
declare function defineSlots<const Slot extends string, const Variants extends SlotRecipeVariants, const DefaultedName extends keyof Variants = never>(config: SlotRecipeConfig<Slot, Variants, DefaultedName>): SlotRecipe<Slot, SlotRecipeProps<Slot, Variants, DefaultedName>>;
99
/** Creates a slot recipe that may compose others, as a library's helper does. */
1010
declare function defineComposedSlots<const Slot extends string, const Variants extends SlotRecipeVariants, const DefaultedName extends keyof ComposedVariants<Composed, Variants> = never, const Composed extends readonly ComposableKindSlotRecipe<string>[] = readonly []>(config: SlotRecipeConfig<Slot, Variants, DefaultedName, Composed>): ReturnType<typeof sva<Slot, Variants, DefaultedName, Composed>>;
11-
export { define, defineComposed, defineComposedSlots, defineSlots };
11+
/** Adds a footer to any slot recipe, as a library's own helper does. */
12+
declare function withFooter<const Base extends ComposableKindSlotRecipe<string>>(base: Base): ReturnType<typeof sva<"footer", {
13+
readonly dense: {
14+
readonly true: {
15+
readonly footer: "pt-2";
16+
};
17+
};
18+
}, never, readonly [Base]>>;
19+
export { define, defineComposed, defineComposedSlots, defineSlots, withFooter };

‎api/consumers/class-recipe/index.d.ts‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ declare const button: ((props: {
5050
type ButtonVariants = VariantsOf<typeof button>;
5151
type ButtonProps = PropsOf<typeof button>;
5252
declare const card: NoInfer<((props: {
53-
readonly classNames?: Readonly<Partial<Record<"root" | "title", string | undefined>>> | undefined;
53+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"root" | "title"> | undefined;
5454
readonly size: "md";
5555
}) => Readonly<Record<"root" | "title", string>>) & {
5656
readonly variantKeys: readonly "size"[];
@@ -92,7 +92,7 @@ declare const pill: ((props: {
9292
}, never, string, undefined> | undefined;
9393
};
9494
declare const field: NoInfer<((props?: {
95-
readonly classNames?: Readonly<Partial<Record<"input" | "label", string | undefined>>> | undefined;
95+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"input" | "label"> | undefined;
9696
readonly invalid?: import("@lynstack/class-recipe").VariantOption<{
9797
readonly true: {
9898
readonly input: "border-red-600";
@@ -170,7 +170,7 @@ declare const iconButton: ((props: {
170170
}, "size", string, undefined> | undefined;
171171
};
172172
declare const select: NoInfer<((props?: {
173-
readonly classNames?: Readonly<Partial<Record<"input" | "label" | "trigger", string | undefined>>> | undefined;
173+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"input" | "label" | "trigger"> | undefined;
174174
readonly invalid?: import("@lynstack/class-recipe").VariantOption<{
175175
readonly true: {
176176
readonly input: "border-red-600";
@@ -259,12 +259,12 @@ declare const compactButton: ((props: {
259259
};
260260
declare const compactButtonClassName: string;
261261
declare const alert: import("@lynstack/class-recipe").SlotRecipe<"root" | "title", {
262-
readonly classNames?: Readonly<Partial<Record<"root" | "title", string | undefined>>> | undefined;
262+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"root" | "title"> | undefined;
263263
readonly tone: "danger";
264264
}>;
265265
declare const alertClassNames: Readonly<Record<"root" | "title", string>>;
266266
declare const toggle: NoInfer<((props?: {
267-
readonly classNames?: Readonly<Partial<Record<"icon" | "root", string | undefined>>> | undefined;
267+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"icon" | "root"> | undefined;
268268
readonly pressed?: import("@lynstack/class-recipe").VariantOption<{
269269
readonly true: {
270270
readonly icon: "opacity-100";
@@ -338,7 +338,7 @@ declare const cardOptions: {
338338
declare const buttonProps: ButtonProps;
339339
declare const cardProps: PropsOf<typeof card>;
340340
declare const panel: NoInfer<((props: {
341-
readonly classNames?: Readonly<Partial<Record<"footer" | "root" | "title", string | undefined>>> | undefined;
341+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"footer" | "root" | "title"> | undefined;
342342
readonly size: import("@lynstack/class-recipe").VariantOption<{
343343
readonly md: {
344344
readonly root: "p-4";
@@ -376,5 +376,38 @@ declare const panel: NoInfer<((props: {
376376
}, never, string, readonly ("footer" | "root" | "title")[]> | undefined;
377377
}>;
378378
declare const panelClassNames: Readonly<Record<"root" | "title" | "footer", string>>;
379-
export { alert, alertClassNames, badge, badgeClassName, button, buttonDefaults, buttonProps, buttonKeys, buttonOptions, card, cardKeys, cardOptions, cardProps, className, compactButton, compactButtonClassName, field, iconButton, iconClassName, merged, panel, panelClassNames, pill, select, selectClassNames, toggle, toggleClassNames, toggleRoot, };
379+
declare const footedCard: NoInfer<((props: {
380+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"footer" | "root" | "title"> | undefined;
381+
readonly dense?: import("@lynstack/class-recipe").VariantOption<{
382+
readonly true: {
383+
readonly footer: "pt-2";
384+
};
385+
}> | undefined;
386+
readonly size: "md";
387+
}) => Readonly<Record<"footer" | "root" | "title", string>>) & {
388+
readonly variantKeys: readonly ("dense" | "size")[];
389+
readonly variantOptions: {
390+
readonly dense: readonly ("false" | "true")[];
391+
readonly size: readonly "md"[];
392+
};
393+
readonly defaultVariants: {
394+
readonly dense: "false" | "true";
395+
};
396+
} & {
397+
readonly "~composition"?: import("@lynstack/class-recipe").RecipeComposition<{
398+
readonly dense: {
399+
readonly true: {
400+
readonly footer: "pt-2";
401+
};
402+
};
403+
readonly size: {
404+
readonly md: {
405+
readonly root: "p-4";
406+
readonly title: "text-base";
407+
};
408+
};
409+
}, never, string, readonly ("footer" | "root" | "title")[]> | undefined;
410+
}>;
411+
declare const footedCardClasses: Readonly<Record<"root" | "title" | "footer", string>>;
412+
export { alert, alertClassNames, badge, badgeClassName, button, buttonDefaults, buttonKeys, buttonOptions, buttonProps, card, cardKeys, cardOptions, cardProps, className, compactButton, compactButtonClassName, field, footedCard, footedCardClasses, iconButton, iconClassName, merged, panel, panelClassNames, pill, select, selectClassNames, toggle, toggleClassNames, toggleRoot, };
380413
export type { ButtonProps, ButtonVariants };

‎api/consumers/class-recipe/look.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { VariantsOf } from "@lynstack/class-recipe";
22
declare const look: NoInfer<((props: {
3-
readonly classNames?: Readonly<Partial<Record<"root", string | undefined>>> | undefined;
3+
readonly classNames?: import("@lynstack/class-recipe").SlotClasses<"root"> | undefined;
44
readonly size: import("@lynstack/class-recipe").VariantOption<{
55
readonly md: {
66
readonly root: "h-8";

‎consumers/class-recipe/define.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,23 @@ function defineComposedSlots<
6060
return sva(config);
6161
}
6262

63-
export { define, defineComposed, defineComposedSlots, defineSlots };
63+
/** Adds a footer to any slot recipe, as a library's own helper does. */
64+
function withFooter<const Base extends ComposableKindSlotRecipe<string>>(
65+
base: Base,
66+
): ReturnType<
67+
typeof sva<
68+
"footer",
69+
{ readonly dense: { readonly true: { readonly footer: "pt-2" } } },
70+
never,
71+
readonly [Base]
72+
>
73+
> {
74+
return sva({
75+
base: { footer: "pt-4" },
76+
composes: [base],
77+
slots: ["footer"],
78+
variants: { dense: { true: { footer: "pt-2" } } },
79+
});
80+
}
81+
82+
export { define, defineComposed, defineComposedSlots, defineSlots, withFooter };

‎consumers/class-recipe/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
defineComposed,
1414
defineComposedSlots,
1515
defineSlots,
16+
withFooter,
1617
} from "./define.js";
1718
import { look } from "./look.js";
1819

@@ -124,16 +125,20 @@ const panel = defineComposedSlots({
124125
const panelClassNames: Readonly<Record<"root" | "title" | "footer", string>> =
125126
panel({ size: "sm" });
126127

128+
const footedCard = withFooter(card);
129+
const footedCardClasses: Readonly<Record<"root" | "title" | "footer", string>> =
130+
footedCard({ dense: true, size: "md" });
131+
127132
export {
128133
alert,
129134
alertClassNames,
130135
badge,
131136
badgeClassName,
132137
button,
133138
buttonDefaults,
134-
buttonProps,
135139
buttonKeys,
136140
buttonOptions,
141+
buttonProps,
137142
card,
138143
cardKeys,
139144
cardOptions,
@@ -142,6 +147,8 @@ export {
142147
compactButton,
143148
compactButtonClassName,
144149
field,
150+
footedCard,
151+
footedCardClasses,
145152
iconButton,
146153
iconClassName,
147154
merged,

‎packages/class-recipe/.oxlintrc.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
"$schema": "../../node_modules/oxlint/configuration_schema.json",
33
"extends": ["../../.oxlintrc.json"],
44
"overrides": [
5+
{
6+
// TypeScript relates an object to one mapped type over slots that
7+
// are generic, as in a function generic over the slot recipe that a
8+
// recipe composes, but not to a Record nested in Partial and Readonly.
9+
"files": ["src/types.ts"],
10+
"rules": { "typescript/consistent-indexed-object-style": "off" }
11+
},
512
{
613
// Guarding each key halves the speed of objects and drops inherited
714
// keys, which clsx keeps.

‎packages/class-recipe/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ tagged `class-recipe@<version>`.
66

77
## Unreleased
88

9+
- Declarations and error messages print the classes of each slot as
10+
`SlotClasses<…>`, and a function generic over the slot recipe it
11+
composes can name its own slots.
912
- `cva` and `sva` warn, once, with `console.warn`, about a default, a
1013
compound variant, or classes that name a variant, an option, or a slot
1114
that the config does not declare, such as a misspelled name in a config

0 commit comments

Comments
 (0)