Skip to content

Commit 46568ff

Browse files
timphandevclaude
andcommitted
fix(native-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 a slot style recipe composes, plain or themed, could not give the recipe's own slots a style in `base`, the variants, or the compound variants: the check of each slot compared it with ComposedSlot, which TypeScript cannot reduce while the composed recipe is generic. The check now compares a slot with the recipe's own slots first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 918d89e commit 46568ff

9 files changed

Lines changed: 237 additions & 23 deletions

File tree

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
import type { ComposableKindRecipe, ComposedVariants, NativeStyle, SlotStyleRecipe, SlotStyleRecipeConfig, SlotStyleRecipeVariants, SlotStyles, StyleRecipe, StyleRecipeConfig, StyleRecipeVariants, VariantSelection } from "@lynstack/native-recipe";
2-
import { createStyleRecipe } from "@lynstack/native-recipe";
1+
import type { ComposableKindRecipe, ComposableKindSlotRecipe, ComposedVariants, NativeStyle, SlotStyleRecipe, SlotStyleRecipeConfig, SlotStyleRecipeVariants, SlotStyles, StyleRecipe, StyleRecipeConfig, StyleRecipeVariants, VariantSelection } from "@lynstack/native-recipe";
2+
import { createSlotStyleRecipe, createStyleRecipe } from "@lynstack/native-recipe";
33
/** Creates a recipe from any config, as a library's own helper does. */
44
declare function define<const Variants extends StyleRecipeVariants, const DefaultedName extends keyof Variants = never>(config: StyleRecipeConfig<Variants, NativeStyle, readonly [], DefaultedName>): StyleRecipe<VariantSelection<Variants, DefaultedName>, NativeStyle>;
55
/** Creates a recipe that may compose others, as a library's own helper does. */
66
declare function defineComposed<const Variants extends StyleRecipeVariants, const DefaultedName extends keyof ComposedVariants<Composed, Variants> = never, const Composed extends readonly ComposableKindRecipe<NativeStyle>[] = readonly []>(config: StyleRecipeConfig<Variants, never, readonly [], DefaultedName, Composed>): ReturnType<typeof createStyleRecipe<Variants, never, readonly [], DefaultedName, Composed>>;
77
/** Creates a slot recipe from any config, as a library's own helper does. */
88
declare function defineSlots<const Slot extends string, const Variants extends SlotStyleRecipeVariants, const DefaultedName extends keyof Variants = never>(config: SlotStyleRecipeConfig<Slot, Variants, SlotStyles<Slot>, readonly [], DefaultedName>): SlotStyleRecipe<VariantSelection<Variants, DefaultedName>, SlotStyles<Slot>>;
9-
export { define, defineComposed, defineSlots };
9+
/** Adds a footer to any slot recipe, as a library's own helper does. */
10+
declare function withFooter<const Base extends ComposableKindSlotRecipe<NativeStyle>>(base: Base): ReturnType<typeof createSlotStyleRecipe<"footer", {
11+
readonly dense: {
12+
readonly true: {
13+
readonly footer: {
14+
readonly borderStyle: "dashed";
15+
};
16+
};
17+
};
18+
}, {
19+
readonly footer: {
20+
readonly borderStyle: "solid";
21+
};
22+
}, readonly [], never, readonly [Base]>>;
23+
export { define, defineComposed, defineSlots, withFooter };

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,5 +442,57 @@ declare const compactBox: ((props: {
442442
}, "size", NativeStyle, undefined> | undefined;
443443
};
444444
declare const compactBoxStyle: NativeStyle;
445-
export { compactBox, compactBoxStyle, anyChip, anyStyle, box, boxDefaults, boxKeys, boxOptions, button, chip, chipKeys, chipOptions, chipStyle, createArrayStyleRecipes, fade, fadeStyle, field, fieldStyles, iconBox, iconBoxStyle, iconChip, iconChipStyle, iconStyle, labelStyle, labeledButton, rootStyle, style, tag, tagLabelStyle, };
445+
declare const footedButton: ((props: {
446+
readonly dense?: import("@lynstack/native-recipe").VariantOption<{
447+
readonly true: {
448+
readonly footer: {
449+
readonly borderStyle: "dashed";
450+
};
451+
};
452+
}> | undefined;
453+
readonly size: "md";
454+
}) => {
455+
readonly footer: {
456+
readonly borderStyle?: "dashed" | "solid" | undefined;
457+
};
458+
readonly label: {
459+
readonly fontSize?: 16 | undefined;
460+
readonly fontWeight?: "600" | undefined;
461+
};
462+
readonly root: {
463+
readonly alignItems?: "center" | undefined;
464+
readonly height?: 40 | undefined;
465+
};
466+
}) & {
467+
readonly variantKeys: readonly ("dense" | "size")[];
468+
readonly variantOptions: {
469+
readonly dense: readonly ("false" | "true")[];
470+
readonly size: readonly "md"[];
471+
};
472+
readonly defaultVariants: {
473+
readonly dense: "false" | "true";
474+
};
475+
} & {
476+
readonly "~composition"?: import("@lynstack/native-recipe").RecipeComposition<{
477+
readonly dense: {
478+
readonly true: {
479+
readonly footer: {
480+
readonly borderStyle: "dashed";
481+
};
482+
};
483+
};
484+
readonly size: {
485+
readonly md: {
486+
readonly label: {
487+
readonly fontSize: 16;
488+
};
489+
readonly root: {
490+
readonly height: 40;
491+
};
492+
};
493+
};
494+
}, never, NativeStyle, readonly ("footer" | "label" | "root")[]> | undefined;
495+
};
496+
declare const footedButtonStyles: Readonly<Record<"root" | "label" | "footer", NativeStyle>>;
497+
export { compactBox, compactBoxStyle, footedButton, footedButtonStyles, anyChip, anyStyle, box, boxDefaults, boxKeys, boxOptions, button, chip, chipKeys, chipOptions, chipStyle, createArrayStyleRecipes, fade, fadeStyle, field, fieldStyles, iconBox, iconBoxStyle, iconChip, iconChipStyle, iconStyle, labelStyle, labeledButton, rootStyle, style, tag, tagLabelStyle, };
446498
export type { BoxVariants, ChipVariants, IconBoxVariants };

‎api/native-recipe.d.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ type UnknownKey<Style> = Exclude<KeyOfEach<Style>, StyleKey>;
1313
type PropertyOfEach<Style, Key extends PropertyKey> = { [Property in Key]: Style extends unknown ? Property extends keyof Style ? Style[Property] : never : never; }[Key];
1414
type OptionValue<Variants> = { [Name in keyof Variants]: Variants[Name][keyof Variants[Name]]; }[keyof Variants];
1515
type SlotStyles<Slot extends string> = Readonly<Partial<Record<Slot, NativeStyle | undefined>>>;
16-
type NoUnknownSlotStyles<Styles, Slot extends string> = string extends keyof Styles ? unknown : { readonly [Name in keyof Styles]: Name extends Slot ? NoUnknownProperties<NonNullable<Styles[Name]>> : never; };
17-
type NoUnknownVariantStyles<Variants, Slot extends string> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: NoUnknownSlotStyles<Variants[Name][Option], Slot>; }; };
18-
type NoUnknownCompoundStyles<Styles, Slot extends string> = Readonly<Partial<Record<Exclude<KeyOfEach<Styles>, Slot>, never>>> & { readonly [Name in Slot]?: NoUnknownProperties<DeclaredStyle$1<Styles, Name>>; };
16+
type NoUnknownSlotStyles<Styles, Slot extends string, Inherited extends string = never> = string extends keyof Styles ? unknown : { readonly [Name in keyof Styles]: Name extends Slot ? NoUnknownProperties<NonNullable<Styles[Name]>> : Name extends Inherited ? NoUnknownProperties<NonNullable<Styles[Name]>> : never; };
17+
type NoUnknownVariantStyles<Variants, Slot extends string, Inherited extends string = never> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: NoUnknownSlotStyles<Variants[Name][Option], Slot, Inherited>; }; };
18+
type InheritedSlot<Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[]> = NonNullable<Composed[number]["~composition"]>["slots"][number];
19+
type NoUnknownCompoundStyles<Styles, Slot extends string, Inherited extends string = never> = Readonly<Partial<Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>>> & { readonly [Name in Slot | Inherited]?: NoUnknownProperties<DeclaredStyle$1<Styles, Name>>; };
1920
type DeclaredStyle$1<Styles, Slot> = Styles extends unknown ? Slot extends keyof Styles ? Exclude<Styles[Slot], undefined> : never : never;
2021
type DeclaredSlotStyles<Variants, Base, Compounds, Composed> = Base | OptionValue<Variants> | CompoundStyles<Compounds> | ComposedStyle<Composed>;
2122
type CompoundStyles<Compounds> = Compounds extends readonly (infer Compound)[] ? Compound extends {
@@ -36,9 +37,9 @@ interface SlotStyleCompoundVariant<Variants, Styles = SlotStyles<string>> {
3637
interface SlotStyleRecipeConfig<Slot extends string, Variants, Base, Compounds, DefaultedName extends keyof ComposedVariants$1<Composed, Variants>, Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[] = readonly []> {
3738
readonly composes?: Composed | undefined;
3839
readonly slots: readonly Slot[];
39-
readonly base?: (Base & SlotStyles<string> & NoUnknownSlotStyles<Base, NoInfer<ComposedSlot$1<Composed, Slot>>>) | undefined;
40-
readonly variants: Variants & SlotStyleRecipeVariants & NoUnknownVariantStyles<Variants, NoInfer<ComposedSlot$1<Composed, Slot>>>;
41-
readonly compoundVariants?: (Compounds & readonly SlotStyleCompoundVariant<NoInfer<ComposedVariants$1<Composed, Variants>>, NoUnknownCompoundStyles<CompoundStyles<Compounds>, NoInfer<ComposedSlot$1<Composed, Slot>>>>[]) | undefined;
40+
readonly base?: (Base & SlotStyles<string> & NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>) | undefined;
41+
readonly variants: Variants & SlotStyleRecipeVariants & NoUnknownVariantStyles<Variants, NoInfer<Slot>, InheritedSlot<Composed>>;
42+
readonly compoundVariants?: (Compounds & readonly SlotStyleCompoundVariant<NoInfer<ComposedVariants$1<Composed, Variants>>, NoUnknownCompoundStyles<CompoundStyles<Compounds>, NoInfer<Slot>, InheritedSlot<Composed>>>[]) | undefined;
4243
readonly defaultVariants?: DefaultVariants<ComposedVariants$1<Composed, Variants>, DefaultedName> | undefined;
4344
readonly cache?: boolean | undefined;
4445
}

‎consumers/native-recipe/define.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ComposableKindRecipe,
3+
ComposableKindSlotRecipe,
34
ComposedVariants,
45
NativeStyle,
56
SlotStyleRecipe,
@@ -73,4 +74,29 @@ function defineSlots<
7374
return createSlotStyleRecipe(config);
7475
}
7576

76-
export { define, defineComposed, defineSlots };
77+
/** Adds a footer to any slot recipe, as a library's own helper does. */
78+
function withFooter<const Base extends ComposableKindSlotRecipe<NativeStyle>>(
79+
base: Base,
80+
): ReturnType<
81+
typeof createSlotStyleRecipe<
82+
"footer",
83+
{
84+
readonly dense: {
85+
readonly true: { readonly footer: { readonly borderStyle: "dashed" } };
86+
};
87+
},
88+
{ readonly footer: { readonly borderStyle: "solid" } },
89+
readonly [],
90+
never,
91+
readonly [Base]
92+
>
93+
> {
94+
return createSlotStyleRecipe({
95+
base: { footer: { borderStyle: "solid" } },
96+
composes: [base],
97+
slots: ["footer"],
98+
variants: { dense: { true: { footer: { borderStyle: "dashed" } } } },
99+
});
100+
}
101+
102+
export { define, defineComposed, defineSlots, withFooter };

‎consumers/native-recipe/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
createThemedRecipes,
1212
} from "@lynstack/native-recipe";
1313

14-
import { define, defineComposed, defineSlots } from "./define.js";
14+
import { define, defineComposed, defineSlots, withFooter } from "./define.js";
1515

1616
const box = createStyleRecipe({
1717
base: { borderRadius: 8 },
@@ -134,9 +134,16 @@ const compactBoxStyle: NativeStyle = compactBox({
134134
tone: "neutral",
135135
});
136136

137+
const footedButton = withFooter(button);
138+
const footedButtonStyles: Readonly<
139+
Record<"root" | "label" | "footer", NativeStyle>
140+
> = footedButton({ dense: true, size: "md" });
141+
137142
export {
138143
compactBox,
139144
compactBoxStyle,
145+
footedButton,
146+
footedButtonStyles,
140147
anyChip,
141148
anyStyle,
142149
box,

‎packages/native-recipe/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ published on npm and as a
77

88
## Unreleased
99

10+
- A function generic over the slot recipe that a slot style recipe
11+
composes, plain or themed, can name its own slots.
1012
- The recipe creators warn, once, with `console.warn`, about a default, a
1113
compound variant, or a style that names a variant, an option, or a slot
1214
that the config does not declare, such as a misspelled name in a config
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, expectTypeOf, it } from "vitest";
2+
3+
import type { ComposableKindSlotRecipe, NativeStyle } from "./types.js";
4+
import { createSlotStyleRecipe } from "./slot-style-recipe.js";
5+
6+
const field = createSlotStyleRecipe({
7+
slots: ["root", "label"],
8+
base: { root: { gap: 4 }, label: { fontSize: 14 } },
9+
variants: { invalid: { true: { label: { color: "#b91c1c" } } } },
10+
});
11+
12+
/** Adds a footer to any slot recipe, as a library's own helper does. */
13+
function withFooter<const Base extends ComposableKindSlotRecipe<NativeStyle>>(
14+
base: Base,
15+
): ReturnType<
16+
typeof createSlotStyleRecipe<
17+
"footer",
18+
{
19+
readonly dense: {
20+
readonly true: { readonly footer: { readonly paddingTop: 8 } };
21+
};
22+
},
23+
{ readonly footer: { readonly paddingTop: 16 } },
24+
readonly [],
25+
never,
26+
readonly [Base]
27+
>
28+
> {
29+
return createSlotStyleRecipe({
30+
composes: [base],
31+
slots: ["footer"],
32+
base: { footer: { paddingTop: 16 } },
33+
variants: { dense: { true: { footer: { paddingTop: 8 } } } },
34+
});
35+
}
36+
37+
/** Adds a footer, and a style for a slot that no recipe declares. */
38+
function withIcon<const Base extends ComposableKindSlotRecipe<NativeStyle>>(
39+
base: Base,
40+
): ReturnType<
41+
typeof createSlotStyleRecipe<
42+
"footer",
43+
{
44+
readonly dense: {
45+
readonly true: { readonly footer: { readonly paddingTop: 8 } };
46+
};
47+
},
48+
{ readonly icon: { readonly width: 16 } },
49+
readonly [],
50+
never,
51+
readonly [Base]
52+
>
53+
> {
54+
return createSlotStyleRecipe({
55+
composes: [base],
56+
slots: ["footer"],
57+
// @ts-expect-error: icon is not a slot of the recipe.
58+
base: { icon: { width: 16 } },
59+
variants: { dense: { true: { footer: { paddingTop: 8 } } } },
60+
});
61+
}
62+
63+
describe("a slot style recipe in a function generic over the slot recipe it composes", () => {
64+
it("accepts its own slots", () => {
65+
const footed = withFooter(field);
66+
67+
expect(footed({ dense: true })).toStrictEqual({
68+
root: { gap: 4 },
69+
label: { fontSize: 14 },
70+
footer: { paddingTop: 8 },
71+
});
72+
expectTypeOf(footed.variantKeys).toEqualTypeOf<
73+
readonly ("invalid" | "dense")[]
74+
>();
75+
});
76+
77+
it("rejects a slot that it does not declare", () => {
78+
expect(withIcon(field)()).toStrictEqual({
79+
root: { gap: 4 },
80+
label: { fontSize: 14 },
81+
footer: {},
82+
});
83+
});
84+
});

‎packages/native-recipe/src/slot-style-recipe.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type {
22
ComposableKindSlotRecipe,
3-
ComposedSlot,
43
ComposedVariants,
54
KindRecipe,
65
KindVariants,
@@ -12,6 +11,7 @@ import type {
1211
CompoundStyles,
1312
DefaultVariants,
1413
InheritedDefaultedName,
14+
InheritedSlot,
1515
NativeStyle,
1616
NoUnknownCompoundStyles,
1717
NoUnknownSlotStyles,
@@ -84,12 +84,12 @@ interface SlotStyleRecipeConfig<
8484
readonly base?:
8585
| (Base &
8686
SlotStyles<string> &
87-
NoUnknownSlotStyles<Base, NoInfer<ComposedSlot<Composed, Slot>>>)
87+
NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>)
8888
| undefined;
8989
/** For each variant name, the style of each slot for each of its options. */
9090
readonly variants: Variants &
9191
SlotStyleRecipeVariants &
92-
NoUnknownVariantStyles<Variants, NoInfer<ComposedSlot<Composed, Slot>>>;
92+
NoUnknownVariantStyles<Variants, NoInfer<Slot>, InheritedSlot<Composed>>;
9393
/**
9494
* Styles added to some slots when several variants have particular
9595
* options at the same time, applied in order after the styles of the
@@ -101,7 +101,8 @@ interface SlotStyleRecipeConfig<
101101
NoInfer<ComposedVariants<Composed, Variants>>,
102102
NoUnknownCompoundStyles<
103103
CompoundStyles<Compounds>,
104-
NoInfer<ComposedSlot<Composed, Slot>>
104+
NoInfer<Slot>,
105+
InheritedSlot<Composed>
105106
>
106107
>[])
107108
| undefined;

‎packages/native-recipe/src/types.ts‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
ComposableKindSlotRecipe,
23
ComposedDefaultedName,
34
ComposedVariants,
45
VariantsOf as RecipeVariantsOf,
@@ -79,36 +80,61 @@ type SlotStyles<Slot extends string> = Readonly<
7980
/**
8081
* Rejects the slots and style properties of an option's styles that the
8182
* slot recipe does not have, unless the option's slot names are not known
82-
* at compile time.
83+
* at compile time. A recipe's own slots, `Slot`, are matched before those
84+
* of the slot recipes it composes, `Inherited`, so that they are accepted
85+
* even when the inherited slots are generic.
8386
*/
8487
type NoUnknownSlotStyles<
8588
Styles,
8689
Slot extends string,
90+
Inherited extends string = never,
8791
> = string extends keyof Styles
8892
? unknown
8993
: {
9094
readonly [Name in keyof Styles]: Name extends Slot
9195
? NoUnknownProperties<NonNullable<Styles[Name]>>
92-
: never;
96+
: Name extends Inherited
97+
? NoUnknownProperties<NonNullable<Styles[Name]>>
98+
: never;
9399
};
94100

95-
type NoUnknownVariantStyles<Variants, Slot extends string> = {
101+
type NoUnknownVariantStyles<
102+
Variants,
103+
Slot extends string,
104+
Inherited extends string = never,
105+
> = {
96106
readonly [Name in keyof Variants]: {
97107
readonly [Option in keyof Variants[Name]]: NoUnknownSlotStyles<
98108
Variants[Name][Option],
99-
Slot
109+
Slot,
110+
Inherited
100111
>;
101112
};
102113
};
103114

115+
/**
116+
* The slots of the slot recipes of `Composed`, as indexed access rather
117+
* than a conditional type, so that TypeScript relates a config to them
118+
* while `Composed` is generic.
119+
*/
120+
type InheritedSlot<
121+
Composed extends readonly ComposableKindSlotRecipe<NativeStyle>[],
122+
> = NonNullable<Composed[number]["~composition"]>["slots"][number];
123+
104124
/**
105125
* Rejects the slots and style properties of the compound variants' styles,
106126
* given as a union, that the slot recipe does not have.
107127
*/
108-
type NoUnknownCompoundStyles<Styles, Slot extends string> = Readonly<
109-
Partial<Record<Exclude<KeyOfEach<Styles>, Slot>, never>>
128+
type NoUnknownCompoundStyles<
129+
Styles,
130+
Slot extends string,
131+
Inherited extends string = never,
132+
> = Readonly<
133+
Partial<Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>>
110134
> & {
111-
readonly [Name in Slot]?: NoUnknownProperties<DeclaredStyle<Styles, Name>>;
135+
readonly [Name in Slot | Inherited]?: NoUnknownProperties<
136+
DeclaredStyle<Styles, Name>
137+
>;
112138
};
113139

114140
/** Every style that a slot recipe's config declares for `Slot`, as a union. */
@@ -222,6 +248,7 @@ export type {
222248
DeclaredStyle,
223249
DefaultedNameOf,
224250
InheritedDefaultedName,
251+
InheritedSlot,
225252
KeyOfEach,
226253
NativeStyle,
227254
NoUnknownCompoundStyles,

0 commit comments

Comments
 (0)