@multiplatform.one/theme 6.7.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/dates.spec.ts +344 -1
- package/src/dates.ts +50 -3
- package/src/devWarn.ts +29 -1
- package/src/figma/figmaTokens.spec.ts +3 -3
- package/src/figma/figmaTokens.ts +6 -3
- package/src/keyboardFocusRing.ts +61 -5
- package/src/menuRow.spec.ts +2 -2
- package/src/menuRow.ts +5 -4
- package/src/theme/Surface.spec.tsx +146 -0
- package/src/theme/Surface.tsx +73 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
- package/src/theme/createDefaultThemeConfig.ts +47 -3
- package/src/theme/createThemes.ts +27 -4
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
- package/src/theme/focusState.ts +9 -1
- package/src/theme/groupGeometry.spec.ts +108 -0
- package/src/theme/groupGeometry.ts +119 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +17 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/layoutTokens.spec.ts +14 -0
- package/src/theme/layoutTokens.ts +29 -0
- package/src/theme/recipeInputs.ts +368 -0
- package/src/theme/recipes.ts +34 -0
- package/src/theme/resolveKnobs.spec.ts +91 -9
- package/src/theme/resolveKnobs.ts +55 -5
- package/src/theme/shared.tsx +11 -0
- package/src/theme/sizeRecipes.spec.ts +296 -0
- package/src/theme/sizeRecipes.ts +198 -0
- package/src/theme/useResolvedKnobs.ts +76 -24
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
- package/types/dates.d.ts +36 -2
- package/types/dates.d.ts.map +1 -1
- package/types/devWarn.d.ts +16 -1
- package/types/devWarn.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +19 -4
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/menuRow.d.ts +5 -4
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +37 -0
- package/types/theme/Surface.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +14 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +8 -0
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/groupGeometry.d.ts +69 -0
- package/types/theme/groupGeometry.d.ts.map +1 -0
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +2 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +22 -0
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +110 -0
- package/types/theme/recipeInputs.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +47 -0
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +7 -3
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/shared.d.ts +9 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +68 -0
- package/types/theme/sizeRecipes.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +10 -6
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/src/audit/baselines/home-pokemon.json +0 -355
package/src/theme/shared.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { type CreateTamaguiProps, createTamagui } from "tamagui";
|
|
|
3
3
|
import type { Knobs } from "./knobs";
|
|
4
4
|
import { defaultKnobs } from "./knobs";
|
|
5
5
|
import type { Preset } from "./preset.types";
|
|
6
|
+
import type { RecipeFamilies, RecipeInputs } from "./recipeInputs";
|
|
6
7
|
|
|
7
8
|
// ── Preset registry ───────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -83,6 +84,12 @@ export type ThemeConfig<T extends CreateTamaguiProps = CreateTamaguiProps> = {
|
|
|
83
84
|
* time so consumers don't need to introspect the Tamagui config.
|
|
84
85
|
*/
|
|
85
86
|
themeColors: string[];
|
|
87
|
+
/**
|
|
88
|
+
* Size-recipe inputs + generated families (LC-70). Orthogonal to Tamagui
|
|
89
|
+
* `themes` (those are color palettes from createThemes / theme-builder).
|
|
90
|
+
*/
|
|
91
|
+
recipeInputs?: RecipeInputs;
|
|
92
|
+
recipeFamilies?: RecipeFamilies;
|
|
86
93
|
};
|
|
87
94
|
|
|
88
95
|
/**
|
|
@@ -124,6 +131,8 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
|
|
|
124
131
|
options?: {
|
|
125
132
|
presets?: Record<string, Preset>;
|
|
126
133
|
defaultPreset?: string;
|
|
134
|
+
recipeInputs?: RecipeInputs;
|
|
135
|
+
recipeFamilies?: RecipeFamilies;
|
|
127
136
|
},
|
|
128
137
|
): ThemeConfig<T> {
|
|
129
138
|
// Extract color names from the raw themes BEFORE createTamagui processes them.
|
|
@@ -151,5 +160,7 @@ export function createThemeConfig<T extends CreateTamaguiProps>(
|
|
|
151
160
|
presets: options?.presets,
|
|
152
161
|
defaultPreset: options?.defaultPreset,
|
|
153
162
|
themeColors,
|
|
163
|
+
recipeInputs: options?.recipeInputs,
|
|
164
|
+
recipeFamilies: options?.recipeFamilies,
|
|
154
165
|
};
|
|
155
166
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { defaultKnobs } from "./knobs";
|
|
3
|
+
import type { Knobs } from "./knobs";
|
|
4
|
+
import { resolveKnobs } from "./resolveKnobs";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SIZE_RECIPE_INPUTS,
|
|
7
|
+
SIZE_RECIPE_COMPACT_RATIOS,
|
|
8
|
+
SIZE_RECIPE_HEIGHTS,
|
|
9
|
+
SIZE_RECIPE_HEIGHTS_TOUCH,
|
|
10
|
+
SIZE_RECIPE_RATIOS,
|
|
11
|
+
applyTouchFloor,
|
|
12
|
+
defaultRecipeInputs,
|
|
13
|
+
generateSizeRecipeTables,
|
|
14
|
+
generateSizeRecipes,
|
|
15
|
+
getSizeRecipeFamily,
|
|
16
|
+
recipeFamilies,
|
|
17
|
+
setSizeRecipeInputs,
|
|
18
|
+
sizeRecipeBoxVariants,
|
|
19
|
+
sizeRecipeEscape,
|
|
20
|
+
sizeRecipeForToken,
|
|
21
|
+
sizeRecipeFromHeight,
|
|
22
|
+
sizeRecipeIconVariants,
|
|
23
|
+
sizeRecipes,
|
|
24
|
+
sizeRecipeTypeVariants,
|
|
25
|
+
type SizeRecipe,
|
|
26
|
+
} from "./sizeRecipes";
|
|
27
|
+
|
|
28
|
+
function knobsWith(overrides: Partial<Knobs>): Knobs {
|
|
29
|
+
return { ...defaultKnobs, ...overrides };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RECIPE_KEYS = ["$1", "$1.5", "$2", "$2.5", "$3", "$3.5", "$4", "$true", "$5", "$6"] as const;
|
|
33
|
+
|
|
34
|
+
function expectNoRadius(value: object) {
|
|
35
|
+
expect(value).not.toHaveProperty("radius");
|
|
36
|
+
expect(value).not.toHaveProperty("borderRadius");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("size recipes", () => {
|
|
40
|
+
it("medium / $4 matches the 32/12/14/16/6 reference", () => {
|
|
41
|
+
const recipe = sizeRecipes.$4;
|
|
42
|
+
expect(recipe).toEqual({
|
|
43
|
+
height: 32,
|
|
44
|
+
paddingHorizontal: 12,
|
|
45
|
+
fontSize: 14,
|
|
46
|
+
iconSize: 16,
|
|
47
|
+
gap: 6,
|
|
48
|
+
});
|
|
49
|
+
expect(sizeRecipeFromHeight(32)).toEqual(recipe);
|
|
50
|
+
expect(sizeRecipeForToken("$4")).toEqual(recipe);
|
|
51
|
+
expect(SIZE_RECIPE_HEIGHTS.$4).toBe(32);
|
|
52
|
+
expect(SIZE_RECIPE_RATIOS).toEqual({
|
|
53
|
+
padX: 12 / 32,
|
|
54
|
+
font: 14 / 32,
|
|
55
|
+
icon: 16 / 32,
|
|
56
|
+
gap: 6 / 32,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("$true equals $4", () => {
|
|
61
|
+
expect(sizeRecipes.$true).toEqual(sizeRecipes.$4);
|
|
62
|
+
expect(SIZE_RECIPE_HEIGHTS.$true).toBe(SIZE_RECIPE_HEIGHTS.$4);
|
|
63
|
+
expect(sizeRecipeForToken("$true")).toEqual(sizeRecipeForToken("$4"));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("radius is not on the recipe or any variant object", () => {
|
|
67
|
+
expectNoRadius(sizeRecipes.$4);
|
|
68
|
+
expectNoRadius(sizeRecipeFromHeight(32));
|
|
69
|
+
expectNoRadius(sizeRecipeForToken("$4"));
|
|
70
|
+
for (const token of RECIPE_KEYS) {
|
|
71
|
+
expectNoRadius(sizeRecipes[token]);
|
|
72
|
+
expectNoRadius(sizeRecipeBoxVariants[token]);
|
|
73
|
+
expectNoRadius(sizeRecipeTypeVariants[token]);
|
|
74
|
+
expectNoRadius(sizeRecipeIconVariants[token]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("static variants match sizeRecipeFromHeight for every token", () => {
|
|
79
|
+
for (const token of RECIPE_KEYS) {
|
|
80
|
+
const recipe = sizeRecipeFromHeight(SIZE_RECIPE_HEIGHTS[token]);
|
|
81
|
+
expect(sizeRecipes[token]).toEqual(recipe);
|
|
82
|
+
expect(sizeRecipeBoxVariants[token]).toEqual({
|
|
83
|
+
height: recipe.height,
|
|
84
|
+
paddingHorizontal: recipe.paddingHorizontal,
|
|
85
|
+
gap: recipe.gap,
|
|
86
|
+
});
|
|
87
|
+
expect(sizeRecipeTypeVariants[token]).toEqual({ fontSize: recipe.fontSize });
|
|
88
|
+
expect(sizeRecipeIconVariants[token]).toEqual({
|
|
89
|
+
width: recipe.iconSize,
|
|
90
|
+
height: recipe.iconSize,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("sizeRecipeForToken falls back to $4", () => {
|
|
96
|
+
expect(sizeRecipeForToken("nope")).toEqual(sizeRecipes.$4);
|
|
97
|
+
expect(sizeRecipeForToken("")).toEqual(sizeRecipes.$4);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("small vs large: height/padX/font/icon/gap all change; borderRadius stays identical", () => {
|
|
101
|
+
const small = resolveKnobs(knobsWith({ size: "small" }));
|
|
102
|
+
const large = resolveKnobs(knobsWith({ size: "large" }));
|
|
103
|
+
const smallRecipe = sizeRecipeForToken(small.knobProps.sizeToken);
|
|
104
|
+
const largeRecipe = sizeRecipeForToken(large.knobProps.sizeToken);
|
|
105
|
+
|
|
106
|
+
expect(small.knobProps.sizeToken).toBe("$3");
|
|
107
|
+
expect(large.knobProps.sizeToken).toBe("$5");
|
|
108
|
+
expect(smallRecipe.height).not.toBe(largeRecipe.height);
|
|
109
|
+
expect(smallRecipe.paddingHorizontal).not.toBe(largeRecipe.paddingHorizontal);
|
|
110
|
+
expect(smallRecipe.fontSize).not.toBe(largeRecipe.fontSize);
|
|
111
|
+
expect(smallRecipe.iconSize).not.toBe(largeRecipe.iconSize);
|
|
112
|
+
expect(smallRecipe.gap).not.toBe(largeRecipe.gap);
|
|
113
|
+
|
|
114
|
+
expect(small.knobProps.control).toEqual({
|
|
115
|
+
height: smallRecipe.height,
|
|
116
|
+
paddingHorizontal: smallRecipe.paddingHorizontal,
|
|
117
|
+
gap: smallRecipe.gap,
|
|
118
|
+
});
|
|
119
|
+
expect(large.knobProps.control).toEqual({
|
|
120
|
+
height: largeRecipe.height,
|
|
121
|
+
paddingHorizontal: largeRecipe.paddingHorizontal,
|
|
122
|
+
gap: largeRecipe.gap,
|
|
123
|
+
});
|
|
124
|
+
expect(small.knobProps.controlType).toEqual({ fontSize: smallRecipe.fontSize });
|
|
125
|
+
expect(large.knobProps.controlType).toEqual({ fontSize: largeRecipe.fontSize });
|
|
126
|
+
expect(small.knobProps.controlIcon).toEqual({
|
|
127
|
+
width: smallRecipe.iconSize,
|
|
128
|
+
height: smallRecipe.iconSize,
|
|
129
|
+
});
|
|
130
|
+
expect(large.knobProps.controlIcon).toEqual({
|
|
131
|
+
width: largeRecipe.iconSize,
|
|
132
|
+
height: largeRecipe.iconSize,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(small.knobProps.borderRadius).toEqual(large.knobProps.borderRadius);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("layout gap still follows the space knob independently of size (T2.1a)", () => {
|
|
139
|
+
const { knobProps } = resolveKnobs(knobsWith({ space: "large", size: "medium" }));
|
|
140
|
+
expect(knobProps.sizeToken).toBe("$4");
|
|
141
|
+
expect(knobProps.gap.gap).toBe("$5");
|
|
142
|
+
expect(knobProps.control.gap).toBe(sizeRecipes.$4.gap);
|
|
143
|
+
});
|
|
144
|
+
it("touch heights floor at 44 and still differentiate size steps", () => {
|
|
145
|
+
const desktop = sizeRecipeForToken("$4");
|
|
146
|
+
expect(desktop).toEqual(sizeRecipes.$4);
|
|
147
|
+
expect(applyTouchFloor(desktop, false).height).toBe(32);
|
|
148
|
+
expect(applyTouchFloor(desktop, true)).toEqual(sizeRecipeFromHeight(48));
|
|
149
|
+
expect(sizeRecipeForToken("$4", { touch: true })).toEqual(sizeRecipeFromHeight(48));
|
|
150
|
+
expect(sizeRecipeForToken("$3", { touch: true }).height).toBe(44);
|
|
151
|
+
expect(sizeRecipeForToken("$5", { touch: true }).height).toBe(56);
|
|
152
|
+
expect(sizeRecipeForToken("$6", { touch: true }).height).toBe(64);
|
|
153
|
+
expect(sizeRecipeBoxVariants.$4.height).toBe(32);
|
|
154
|
+
expectNoRadius(applyTouchFloor(desktop, true));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("SizeRecipe shape", () => {
|
|
159
|
+
it("has no radius field on the type-level contract", () => {
|
|
160
|
+
const recipe: SizeRecipe = sizeRecipes.$4;
|
|
161
|
+
const keys = Object.keys(recipe).sort();
|
|
162
|
+
expect(keys).toEqual(["fontSize", "gap", "height", "iconSize", "paddingHorizontal"]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("generateSizeRecipes (LC-70)", () => {
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
setSizeRecipeInputs();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("DEFAULT $4 is 32/12/14/16/6 with no radius", () => {
|
|
172
|
+
const generated = generateSizeRecipes(DEFAULT_SIZE_RECIPE_INPUTS);
|
|
173
|
+
expect(generated.recipes.$4).toEqual({
|
|
174
|
+
height: 32,
|
|
175
|
+
paddingHorizontal: 12,
|
|
176
|
+
fontSize: 14,
|
|
177
|
+
iconSize: 16,
|
|
178
|
+
gap: 6,
|
|
179
|
+
});
|
|
180
|
+
expectNoRadius(generated.recipes.$4);
|
|
181
|
+
expectNoRadius(generated.boxVariants.$4);
|
|
182
|
+
expect(generated.inputs.radiusPx).toBe(6);
|
|
183
|
+
expect(generated.heights).toEqual({
|
|
184
|
+
$1: 20,
|
|
185
|
+
"$1.5": 24,
|
|
186
|
+
$2: 24,
|
|
187
|
+
"$2.5": 28,
|
|
188
|
+
$3: 28,
|
|
189
|
+
"$3.5": 30,
|
|
190
|
+
$4: 32,
|
|
191
|
+
$true: 32,
|
|
192
|
+
$5: 40,
|
|
193
|
+
$6: 48,
|
|
194
|
+
});
|
|
195
|
+
expect(generated.heightsTouch).toEqual({
|
|
196
|
+
$1: 44,
|
|
197
|
+
"$1.5": 44,
|
|
198
|
+
$2: 44,
|
|
199
|
+
"$2.5": 44,
|
|
200
|
+
$3: 44,
|
|
201
|
+
"$3.5": 44,
|
|
202
|
+
$4: 48,
|
|
203
|
+
$true: 48,
|
|
204
|
+
$5: 56,
|
|
205
|
+
$6: 64,
|
|
206
|
+
});
|
|
207
|
+
expect(generateSizeRecipeTables(defaultRecipeInputs).touchHeights).toEqual(generated.heightsTouch);
|
|
208
|
+
expect(SIZE_RECIPE_HEIGHTS_TOUCH.$4).toBe(48);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("baseHeight: 40 without tokenHeights regenerates $4 and keeps $5 distinct", () => {
|
|
212
|
+
const generated = generateSizeRecipes({ baseHeight: 40 });
|
|
213
|
+
expect(generated.inputs.tokenHeights).toBeUndefined();
|
|
214
|
+
expect(generated.heights.$4).toBe(40);
|
|
215
|
+
expect(generated.recipes.$4).toEqual({
|
|
216
|
+
height: 40,
|
|
217
|
+
paddingHorizontal: Math.round(40 * (12 / 32)),
|
|
218
|
+
fontSize: Math.round(40 * (14 / 32)),
|
|
219
|
+
iconSize: Math.round(40 * (16 / 32)),
|
|
220
|
+
gap: Math.round(40 * (6 / 32)),
|
|
221
|
+
});
|
|
222
|
+
expect(generated.heights.$5).not.toBe(generated.heights.$4);
|
|
223
|
+
expectNoRadius(generated.recipes.$4);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("controlCompact padX is tighter than control at the same $4 height", () => {
|
|
227
|
+
const generated = generateSizeRecipes(DEFAULT_SIZE_RECIPE_INPUTS);
|
|
228
|
+
expect(generated.families.control.recipes.$4.height).toBe(32);
|
|
229
|
+
expect(generated.families.controlCompact.recipes.$4.height).toBe(32);
|
|
230
|
+
expect(generated.families.control.recipes.$4.paddingHorizontal).toBe(12);
|
|
231
|
+
expect(generated.families.controlCompact.recipes.$4.paddingHorizontal).toBe(8);
|
|
232
|
+
expect(generated.families.controlCompact.recipes.$4.paddingHorizontal).toBeLessThan(
|
|
233
|
+
generated.families.control.recipes.$4.paddingHorizontal,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("radiusPx stays on inputs, never on recipes or variants", () => {
|
|
238
|
+
const generated = generateSizeRecipes({ radiusPx: 10 });
|
|
239
|
+
expect(generated.inputs.radiusPx).toBe(10);
|
|
240
|
+
expectNoRadius(generated.recipes.$4);
|
|
241
|
+
expectNoRadius(generated.recipesTouch.$4);
|
|
242
|
+
expectNoRadius(generated.boxVariants.$4);
|
|
243
|
+
expectNoRadius(generated.families.controlCompact.recipes.$4);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("setSizeRecipeInputs({ baseHeight }) regenerates live tables from stepRatio", () => {
|
|
247
|
+
setSizeRecipeInputs({ baseHeight: 36 });
|
|
248
|
+
expect(SIZE_RECIPE_HEIGHTS.$4).toBe(36);
|
|
249
|
+
expect(sizeRecipes.$4.height).toBe(36);
|
|
250
|
+
expect(sizeRecipeForToken("$4").height).toBe(36);
|
|
251
|
+
expect(sizeRecipeBoxVariants.$4.height).toBe(36);
|
|
252
|
+
expect(recipeFamilies.control.heightVariants.$4.height).toBe(36);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("aliases are the generated control family", () => {
|
|
256
|
+
expect(sizeRecipeBoxVariants).toBe(recipeFamilies.control.boxVariants);
|
|
257
|
+
expect(sizeRecipes).toBe(recipeFamilies.control.recipes);
|
|
258
|
+
expect(sizeRecipeTypeVariants).toBe(recipeFamilies.control.typeVariants);
|
|
259
|
+
expect(sizeRecipeIconVariants).toBe(recipeFamilies.control.iconVariants);
|
|
260
|
+
expect(SIZE_RECIPE_HEIGHTS).toBe(recipeFamilies.control.heights);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("sizeRecipeEscape is identity", () => {
|
|
264
|
+
expect(sizeRecipeEscape(32)).toBe(32);
|
|
265
|
+
expect(sizeRecipeEscape("keep")).toBe("keep");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("size recipe families", () => {
|
|
270
|
+
it("control family matches the sizeRecipeBoxVariants default", () => {
|
|
271
|
+
const control = generateSizeRecipes().families.control;
|
|
272
|
+
expect(getSizeRecipeFamily("control")).toEqual(control);
|
|
273
|
+
for (const token of RECIPE_KEYS) {
|
|
274
|
+
expect(control.boxVariants[token]).toEqual(sizeRecipeBoxVariants[token]);
|
|
275
|
+
expect(control.heightVariants[token]).toEqual({ height: SIZE_RECIPE_HEIGHTS[token] });
|
|
276
|
+
}
|
|
277
|
+
expect(control.boxVariants.$4).toEqual({
|
|
278
|
+
height: 32,
|
|
279
|
+
paddingHorizontal: 12,
|
|
280
|
+
gap: 6,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("controlCompact keeps height and tightens padX", () => {
|
|
285
|
+
const compact = getSizeRecipeFamily("controlCompact");
|
|
286
|
+
const control = getSizeRecipeFamily("control");
|
|
287
|
+
expect(compact.ratios).toEqual(SIZE_RECIPE_COMPACT_RATIOS);
|
|
288
|
+
expect(compact.boxVariants.$4.height).toBe(control.boxVariants.$4.height);
|
|
289
|
+
expect(compact.boxVariants.$4.paddingHorizontal).toBeLessThan(
|
|
290
|
+
control.boxVariants.$4.paddingHorizontal,
|
|
291
|
+
);
|
|
292
|
+
expect(compact.boxVariants.$4.paddingHorizontal).toBe(8);
|
|
293
|
+
expect(compact.boxVariants.$4.gap).toBe(4);
|
|
294
|
+
expect(sizeRecipeForToken("$4", { family: "controlCompact" })).toEqual(compact.recipes.$4);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinated control-size recipes (LC-65 SIZE-RECIPE, LC-70 RECIPE-INPUTS).
|
|
3
|
+
*
|
|
4
|
+
* A flat size ramp cannot encode relationships: padding, font, and icon
|
|
5
|
+
* must stay in ratio with height. Radius is excluded — it stays on the
|
|
6
|
+
* borderRadius knob and must never scale with size.
|
|
7
|
+
*
|
|
8
|
+
* Tables are GENERATED from `defaultRecipeInputs` at module eval (Tamagui
|
|
9
|
+
* 2.0.0-rc.41 flattens `styled(View, { variants: { size: tables } })` when
|
|
10
|
+
* the second arg is an object literal and generate ran at module eval).
|
|
11
|
+
* Do NOT pass a function as styled()'s second argument. Functional
|
|
12
|
+
* `"...size": (val) =>` stays runtime unless JSX flattens a static size —
|
|
13
|
+
* reserve it for non-enumerable values only. Do NOT put recipe tables on a
|
|
14
|
+
* `createStyledContext` primitive (`neverFlatten`).
|
|
15
|
+
*
|
|
16
|
+
* Reference md ($4 / $true): height 32, padX 12, font 14, icon 16, gap 6.
|
|
17
|
+
* `sizeToken` remains the recipe KEY (string "$4") so styled() variants
|
|
18
|
+
* can switch; it is not a height scalar.
|
|
19
|
+
*
|
|
20
|
+
* Families: `control` uses `inputs.ratios`. `controlCompact` uses
|
|
21
|
+
* `compactRatios` at the SAME heights (tighter pad/font/icon/gap).
|
|
22
|
+
*
|
|
23
|
+
* On touch (LC-67), `applyTouchFloor` / `sizeRecipeForToken(token, { touch: true })`
|
|
24
|
+
* lift painted height to ≥ 44 while pad/font/icon stay in ratio. Static
|
|
25
|
+
* `sizeRecipe*Variants` stay on the desktop table — runtime fragments
|
|
26
|
+
* carry the floor; consumers spread `knobProps.control` after `size`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_SIZE_RECIPE_INPUTS,
|
|
31
|
+
SIZE_RECIPE_TOKENS,
|
|
32
|
+
SIZE_TOKEN_EXPONENTS,
|
|
33
|
+
TOUCH_HEIGHT_FLOOR,
|
|
34
|
+
cloneSizeRecipeInputs,
|
|
35
|
+
createRecipeFamilies,
|
|
36
|
+
defaultRecipeInputs,
|
|
37
|
+
defaultSizeFactors,
|
|
38
|
+
generateSizeRecipeTables,
|
|
39
|
+
generateSizeRecipes,
|
|
40
|
+
mergeRecipeInputs,
|
|
41
|
+
resolveSizeRecipeInputs,
|
|
42
|
+
} from "./recipeInputs";
|
|
43
|
+
export type {
|
|
44
|
+
GeneratedSizeRecipes,
|
|
45
|
+
RecipeFamilies,
|
|
46
|
+
RecipeFamilyName,
|
|
47
|
+
RecipeInputs,
|
|
48
|
+
SizeRecipe,
|
|
49
|
+
SizeRecipeBoxVariant,
|
|
50
|
+
SizeRecipeFamily,
|
|
51
|
+
SizeRecipeFamilyName,
|
|
52
|
+
SizeRecipeIconVariant,
|
|
53
|
+
SizeRecipeInputs,
|
|
54
|
+
SizeRecipeRatios,
|
|
55
|
+
SizeRecipeTables,
|
|
56
|
+
SizeRecipeToken,
|
|
57
|
+
SizeRecipeTypeVariant,
|
|
58
|
+
} from "./recipeInputs";
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
defaultRecipeInputs,
|
|
62
|
+
generateSizeRecipes,
|
|
63
|
+
sizeRecipeFromHeight as recipeFromHeight,
|
|
64
|
+
type GeneratedSizeRecipes,
|
|
65
|
+
type RecipeFamilyName,
|
|
66
|
+
type RecipeInputs,
|
|
67
|
+
type SizeRecipe,
|
|
68
|
+
type SizeRecipeFamily,
|
|
69
|
+
type SizeRecipeToken,
|
|
70
|
+
} from "./recipeInputs";
|
|
71
|
+
|
|
72
|
+
function replaceRecord(target: Record<string, unknown>, source: Record<string, unknown>): void {
|
|
73
|
+
for (const key of Object.keys(target)) {
|
|
74
|
+
if (!Object.hasOwn(source, key)) delete target[key];
|
|
75
|
+
}
|
|
76
|
+
Object.assign(target, source);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function adoptFamily(target: SizeRecipeFamily, source: SizeRecipeFamily): void {
|
|
80
|
+
Object.assign(target.ratios, source.ratios);
|
|
81
|
+
target.inputs = { ...source.inputs, ratios: target.ratios };
|
|
82
|
+
replaceRecord(target.heights, source.heights);
|
|
83
|
+
replaceRecord(target.touchHeights, source.touchHeights);
|
|
84
|
+
replaceRecord(target.recipes, source.recipes);
|
|
85
|
+
replaceRecord(target.recipesTouch, source.recipesTouch);
|
|
86
|
+
replaceRecord(target.boxVariants, source.boxVariants);
|
|
87
|
+
replaceRecord(target.typeVariants, source.typeVariants);
|
|
88
|
+
replaceRecord(target.iconVariants, source.iconVariants);
|
|
89
|
+
replaceRecord(target.heightVariants, source.heightVariants);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const initial = generateSizeRecipes(defaultRecipeInputs);
|
|
93
|
+
let current: GeneratedSizeRecipes = initial;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Module-eval default families. `styled()` variants MUST spread these
|
|
97
|
+
* objects (or `sizeRecipeBoxVariants`) — not a function — so rc.41 can flatten.
|
|
98
|
+
*/
|
|
99
|
+
export const recipeFamilies = initial.families;
|
|
100
|
+
|
|
101
|
+
/** Back-compat aliases of `recipeFamilies.control` (generated, not handwritten). */
|
|
102
|
+
export const sizeRecipes = recipeFamilies.control.recipes;
|
|
103
|
+
export const sizeRecipeBoxVariants = recipeFamilies.control.boxVariants;
|
|
104
|
+
export const sizeRecipeTypeVariants = recipeFamilies.control.typeVariants;
|
|
105
|
+
export const sizeRecipeIconVariants = recipeFamilies.control.iconVariants;
|
|
106
|
+
export const SIZE_RECIPE_HEIGHTS = recipeFamilies.control.heights;
|
|
107
|
+
export const SIZE_RECIPE_HEIGHTS_TOUCH = recipeFamilies.control.touchHeights;
|
|
108
|
+
export const SIZE_RECIPE_RATIOS = recipeFamilies.control.ratios;
|
|
109
|
+
export const SIZE_RECIPE_COMPACT_RATIOS = recipeFamilies.controlCompact.ratios;
|
|
110
|
+
|
|
111
|
+
export const SIZE_RECIPE_FAMILY_NAMES = ["control", "controlCompact"] as const;
|
|
112
|
+
|
|
113
|
+
function adoptGenerated(next: GeneratedSizeRecipes): GeneratedSizeRecipes {
|
|
114
|
+
adoptFamily(recipeFamilies.control, next.families.control);
|
|
115
|
+
adoptFamily(recipeFamilies.controlCompact, next.families.controlCompact);
|
|
116
|
+
current = {
|
|
117
|
+
...next,
|
|
118
|
+
heights: SIZE_RECIPE_HEIGHTS,
|
|
119
|
+
heightsTouch: SIZE_RECIPE_HEIGHTS_TOUCH,
|
|
120
|
+
recipes: sizeRecipes,
|
|
121
|
+
recipesTouch: recipeFamilies.control.recipesTouch,
|
|
122
|
+
boxVariants: sizeRecipeBoxVariants,
|
|
123
|
+
typeVariants: sizeRecipeTypeVariants,
|
|
124
|
+
iconVariants: sizeRecipeIconVariants,
|
|
125
|
+
families: recipeFamilies,
|
|
126
|
+
};
|
|
127
|
+
return current;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Merge `partial` over defaultRecipeInputs (not the previous override) and rewrite live tables. */
|
|
131
|
+
export function setSizeRecipeInputs(partial: Partial<RecipeInputs> = {}): GeneratedSizeRecipes {
|
|
132
|
+
return adoptGenerated(generateSizeRecipes(partial));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getSizeRecipeInputs(): RecipeInputs {
|
|
136
|
+
return { ...current.inputs, ratios: { ...current.inputs.ratios } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getGeneratedSizeRecipes(): GeneratedSizeRecipes {
|
|
140
|
+
return current;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getSizeRecipeFamily(name: RecipeFamilyName = "control"): SizeRecipeFamily {
|
|
144
|
+
return recipeFamilies[name] ?? recipeFamilies.control;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function sizeRecipeFromHeight(
|
|
148
|
+
height: number,
|
|
149
|
+
ratios: RecipeInputs["ratios"] = current.inputs.ratios,
|
|
150
|
+
): SizeRecipe {
|
|
151
|
+
return recipeFromHeight(height, ratios);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveFamily(family?: RecipeFamilyName | SizeRecipeFamily): SizeRecipeFamily {
|
|
155
|
+
if (family && typeof family === "object") return family;
|
|
156
|
+
return recipeFamilies[family ?? "control"] ?? recipeFamilies.control;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function applyTouchFloor(
|
|
160
|
+
recipe: SizeRecipe,
|
|
161
|
+
touch: boolean,
|
|
162
|
+
family?: RecipeFamilyName | SizeRecipeFamily,
|
|
163
|
+
): SizeRecipe {
|
|
164
|
+
if (!touch) return recipe;
|
|
165
|
+
const tables = resolveFamily(family);
|
|
166
|
+
const token = (Object.keys(tables.heights) as SizeRecipeToken[]).find(
|
|
167
|
+
(key) => tables.heights[key] === recipe.height,
|
|
168
|
+
);
|
|
169
|
+
if (token && tables.recipesTouch[token]) return tables.recipesTouch[token];
|
|
170
|
+
return recipeFromHeight(
|
|
171
|
+
Math.max(recipe.height, tables.inputs.touchFloor),
|
|
172
|
+
tables.ratios,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function sizeRecipeForToken(
|
|
177
|
+
token: string,
|
|
178
|
+
options?: { touch?: boolean; family?: RecipeFamilyName | SizeRecipeFamily },
|
|
179
|
+
): SizeRecipe {
|
|
180
|
+
const tables = resolveFamily(options?.family);
|
|
181
|
+
const table = options?.touch ? tables.recipesTouch : tables.recipes;
|
|
182
|
+
if (Object.hasOwn(table, token)) return table[token as SizeRecipeToken];
|
|
183
|
+
return table.$4;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Identity helper for the LC-65 SIZE-RECIPE ESCAPE lint
|
|
188
|
+
* (`scripts/lint-conventions.mjs`). Wrap a numeric height / minHeight /
|
|
189
|
+
* paddingHorizontal / fontSize that must stay a literal:
|
|
190
|
+
*
|
|
191
|
+
* height={sizeRecipeEscape(20)}
|
|
192
|
+
*
|
|
193
|
+
* Or comment `// size-recipe-escape:` on the previous line or same
|
|
194
|
+
* statement. Pilot: Button/, InputParts/, fields/Select/.
|
|
195
|
+
*/
|
|
196
|
+
export function sizeRecipeEscape<T>(value: T): T {
|
|
197
|
+
return value;
|
|
198
|
+
}
|