Skip to content

Commit 79b6f21

Browse files
authored
chore: migrate text input components
1 parent 67337fd commit 79b6f21

24 files changed

+2321
-5
lines changed

CLAUDE.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,66 @@ Components follow a consistent pattern:
103103
- **Conventional Commits**: Follow conventional commit format for semantic releases
104104
- **Accessibility**: All components must be accessible and support keyboard navigation
105105
- **Storybook**: Every component requires comprehensive stories showing all variants
106+
107+
## Vanilla Extract Migration Guidelines
108+
109+
When working with vanilla-extract components during the migration:
110+
111+
### Component Isolation Rule
112+
113+
**CRITICAL:** Never mix Stitches and vanilla-extract components. Always use vanilla-extract versions of components inside vanilla-extract components.
114+
115+
```tsx
116+
// ❌ Wrong - mixing Stitches and vanilla-extract
117+
import { Box } from '../Box';
118+
import { Label } from '../Label';
119+
<Box css={css}>...</Box>;
120+
121+
// ✅ Correct - use vanilla versions or plain HTML
122+
import { BoxVanilla } from '../Box';
123+
import { LabelVanilla } from '../Label';
124+
<BoxVanilla css={css}>...</BoxVanilla>;
125+
```
126+
127+
**Why:** Stitches components expect Stitches `CSS` type, but vanilla components use `CSSProps` type. Mixing them causes type errors and architectural inconsistency.
128+
129+
**When vanilla version doesn't exist:** Use plain HTML elements (`<div>`, `<span>`, etc.) with manual style processing.
130+
131+
### CSSProps Type System
132+
133+
Understanding the `CSSProps` interface is crucial for proper type safety:
134+
135+
**Key Pattern - `CSSProps['css']` vs `CSSProps`:**
136+
137+
```tsx
138+
// For props that accept CSS properties directly
139+
interface MyComponentProps {
140+
rootCss?: CSSProps['css']; // ✅ Correct - expects inner CSS object
141+
// NOT: rootCss?: CSSProps // ❌ Wrong - expects wrapper with css property
142+
}
143+
144+
// Usage in component
145+
const { style: rootCssStyles, vars: rootVars } = processCSSProp(rootCss, colors);
146+
```
147+
148+
**When to use each:**
149+
150+
- `css?: CSSProps['css']` - For props that pass directly to `processCSSProp()`
151+
- `extends CSSProps` - For components that have a `css` prop on the interface
152+
153+
**Property Name Flexibility:**
154+
155+
The `CSSProps` interface accepts both abbreviated and full property names:
156+
157+
```tsx
158+
// Both work identically
159+
<Component css={{ p: '$4', m: '$2' }} /> // Abbreviated
160+
<Component css={{ padding: '$4', margin: '$2' }} /> // Full names
161+
```
162+
163+
This works because:
164+
165+
1. `CSSProps` has `[key: string]: any` index signature
166+
2. `processCSSProp()` explicitly handles both forms in its switch statement
167+
168+
**When you see `as any` in tests:** This usually indicates a type mismatch. Check if the prop should be `CSSProps['css']` instead of `CSSProps`.

VANILLA_EXTRACT_DEVELOPER_GUIDE.md

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,13 +945,132 @@ See step-by-step guide for complete implementation.
945945

946946
### TypeScript Errors
947947

948-
Use `RecipeVariants` type for recipe-based components:
948+
#### Problem: RecipeVariants returns undefined and variant properties don't exist
949+
950+
Always wrap `RecipeVariants` with `NonNullable` to ensure TypeScript properly extracts variant types:
949951

950952
```tsx
951953
import { RecipeVariants } from '@vanilla-extract/recipes';
954+
955+
// ❌ Bad - may return undefined
952956
type MyComponentVariants = RecipeVariants<typeof myComponentRecipe>;
957+
958+
// ✅ Good - guaranteed to have variant properties
959+
type MyComponentVariants = NonNullable<RecipeVariants<typeof myComponentRecipe>>;
960+
961+
export interface MyComponentOwnProps extends CSSProps {
962+
size?: MyComponentVariants['size'];
963+
variant?: MyComponentVariants['variant'];
964+
}
965+
```
966+
967+
Without `NonNullable`, TypeScript may treat the variants as `undefined`, causing errors like "Property 'size' does not exist on type 'MyComponentVariants'".
968+
969+
#### Problem: Stitches components used in vanilla components cause type errors
970+
971+
**CRITICAL RULE:** Never mix Stitches and vanilla-extract components. Always use vanilla-extract versions inside vanilla components.
972+
973+
```tsx
974+
// ❌ Bad - Type error: CSSProps not assignable to Stitches CSS type
975+
import { Box } from '../Box';
976+
import { Label } from '../Label';
977+
<Box css={rootCss}>...</Box>
978+
<Label variant={variant}>...</Label>
979+
980+
// ✅ Good - Use vanilla-extract components
981+
import { BoxVanilla } from '../Box';
982+
import { LabelVanilla } from '../Label';
983+
<BoxVanilla css={rootCss}>...</BoxVanilla>
984+
<LabelVanilla variant={variant}>...</LabelVanilla>
985+
986+
// ✅ Also good - Use plain HTML when vanilla version doesn't exist
987+
<div style={rootMergedStyles}>...</div>
988+
```
989+
990+
**Why this matters:**
991+
992+
- Stitches components expect Stitches `CSS` type
993+
- Vanilla components use vanilla-extract `CSSProps` type
994+
- Mixing them causes TypeScript errors and architectural inconsistency
995+
- Always prefer vanilla-extract components (`BoxVanilla`, `LabelVanilla`, etc.) over plain HTML for consistency
996+
997+
**Real-world example from TextField migration:**
998+
999+
```tsx
1000+
// Before (wrong):
1001+
import { Box } from '../Box';
1002+
import { Label } from '../Label';
1003+
1004+
// After (correct):
1005+
import { BoxVanilla } from '../Box';
1006+
import { LabelVanilla } from '../Label';
9531007
```
9541008

1009+
#### Problem: Props typed as `CSSProps` require `as any` in tests
1010+
1011+
If you see `as any` being used to pass CSS properties to a component prop, this indicates a type mismatch.
1012+
1013+
**Root cause:** The prop is typed as `CSSProps` but should be `CSSProps['css']`.
1014+
1015+
```tsx
1016+
// ❌ Wrong - requires 'as any' in tests
1017+
interface MyComponentProps {
1018+
rootCss?: CSSProps; // Wrong type
1019+
}
1020+
1021+
// Component implementation
1022+
const { style: rootCssStyles, vars: rootVars } = processCSSProp(rootCss, colors);
1023+
// TypeScript error: rootCss is CSSProps but processCSSProp expects CSSProps['css']
1024+
1025+
// ✅ Correct - no 'as any' needed
1026+
interface MyComponentProps {
1027+
rootCss?: CSSProps['css']; // Correct type
1028+
}
1029+
1030+
// Component implementation
1031+
const { style: rootCssStyles, vars: rootVars } = processCSSProp(rootCss, colors);
1032+
// Works perfectly!
1033+
```
1034+
1035+
**When to use each type:**
1036+
1037+
- `extends CSSProps` - For component interfaces that expose a `css` prop
1038+
- `rootCss?: CSSProps['css']` - For additional CSS props that pass directly to `processCSSProp()`
1039+
1040+
**Example from Textarea component:**
1041+
1042+
```tsx
1043+
// Correct typing:
1044+
export interface TextareaVanillaOwnProps extends CSSProps {
1045+
// ... other props
1046+
rootCss?: CSSProps['css']; // ← Correct: expects inner CSS object
1047+
}
1048+
1049+
// Usage in tests (no 'as any' needed):
1050+
<TextareaVanilla rootCss={{ p: '$4', m: '$2' }} />;
1051+
```
1052+
1053+
**Understanding CSSProps property names:**
1054+
1055+
The `CSSProps` interface accepts both abbreviated and full property names:
1056+
1057+
```tsx
1058+
// Both work identically:
1059+
<Component css={{ p: '$4', m: '$2' }} /> // Abbreviated
1060+
<Component css={{ padding: '$4', margin: '$2' }} /> // Full names
1061+
```
1062+
1063+
This flexibility exists because:
1064+
1065+
1. `CSSProps` has a `[key: string]: any` index signature (line 55 in cssProps.ts)
1066+
2. `processCSSProp()` explicitly handles both forms in its switch statement (lines 214-270)
1067+
1068+
**When debugging type errors:**
1069+
1070+
1. Check if prop is typed as `CSSProps` when it should be `CSSProps['css']`
1071+
2. Verify the prop value is passed directly to `processCSSProp()`
1072+
3. Remove `as any` and fix the type definition instead
1073+
9551074
### Build or Storybook Issues
9561075

9571076
- **Build errors**: Check `vite.config.ts` has `vanillaExtractPlugin()`

colors/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import {
2525

2626
import { badgeDarkTheme, badgeLightTheme } from '../components/Badge/Badge.theme';
2727
import { cardDarkTheme, cardLightTheme } from '../components/Card/Card.theme';
28+
import { inputDarkTheme, inputLightTheme } from '../components/Input/Input.theme';
2829
import { panelDarkTheme, panelLightTheme } from '../components/Panel/Panel.theme';
30+
import { textareaDarkTheme, textareaLightTheme } from '../components/Textarea/Textarea.theme';
2931
import * as deepBlue from './deepBlue';
3032
import * as elevation from './elevation';
3133
import * as grayBlue from './grayBlue';
@@ -71,6 +73,8 @@ export const lightColors: ColorMap = Object.entries(customColors)
7173
...badgeLightTheme,
7274
...cardLightTheme,
7375
...panelLightTheme,
76+
...inputLightTheme,
77+
...textareaLightTheme,
7478
},
7579
);
7680

@@ -99,6 +103,8 @@ export const darkColors: ColorMap = Object.entries(customColors)
99103
...badgeDarkTheme,
100104
...cardDarkTheme,
101105
...panelDarkTheme,
106+
...inputDarkTheme,
107+
...textareaDarkTheme,
102108
},
103109
);
104110

components/Input/Input.stories.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { modifyVariantsForStory } from '../../utils/modifyVariantsForStory';
99
import { Box } from '../Box';
1010
import { Flex } from '../Flex';
1111
import { Label } from '../Label';
12+
import { Text } from '../Text';
1213
import { Input, InputProps, InputVariants } from './Input';
14+
import { InputVanilla } from './Input.vanilla';
1315

1416
const StyledEyeOpenIcon = styled(EyeOpenIcon, {
1517
'@hover': {
@@ -246,4 +248,100 @@ export const Autofill: StoryFn<typeof InputForStory> = (args) => (
246248
</form>
247249
);
248250

251+
export const Comparison: StoryFn = () => {
252+
return (
253+
<Flex css={{ gap: '$3' }}>
254+
{/* Stitches Column */}
255+
<Flex css={{ flex: 1, flexDirection: 'column', gap: '$3' }}>
256+
<Text weight="medium">Stitches</Text>
257+
258+
<Box>
259+
<Label htmlFor="stitches-small">Small</Label>
260+
<Input id="stitches-small" size="small" placeholder="Small input" />
261+
</Box>
262+
263+
<Box>
264+
<Label htmlFor="stitches-default">Default</Label>
265+
<Input id="stitches-default" placeholder="Default input" />
266+
</Box>
267+
268+
<Box>
269+
<Label htmlFor="stitches-large">Large</Label>
270+
<Input id="stitches-large" size="large" placeholder="Large input" />
271+
</Box>
272+
273+
<Box>
274+
<Label htmlFor="stitches-ghost">Ghost</Label>
275+
<Input id="stitches-ghost" variant="ghost" placeholder="Ghost input" />
276+
</Box>
277+
278+
<Box>
279+
<Label htmlFor="stitches-invalid">Invalid</Label>
280+
<Input id="stitches-invalid" state="invalid" placeholder="Invalid input" />
281+
</Box>
282+
283+
<Box>
284+
<Label htmlFor="stitches-disabled">Disabled</Label>
285+
<Input id="stitches-disabled" disabled defaultValue="Disabled input" />
286+
</Box>
287+
288+
<Box>
289+
<Label htmlFor="stitches-adornments">Adornments</Label>
290+
<Input
291+
id="stitches-adornments"
292+
startAdornment={<MagnifyingGlassIcon />}
293+
endAdornment={<StyledEyeOpenIcon />}
294+
placeholder="With adornments"
295+
/>
296+
</Box>
297+
</Flex>
298+
299+
{/* Vanilla Extract Column */}
300+
<Flex css={{ flex: 1, flexDirection: 'column', gap: '$3' }}>
301+
<Text weight="medium">Vanilla Extract</Text>
302+
303+
<Box>
304+
<Label htmlFor="vanilla-small">Small</Label>
305+
<InputVanilla id="vanilla-small" size="small" placeholder="Small input" />
306+
</Box>
307+
308+
<Box>
309+
<Label htmlFor="vanilla-default">Default</Label>
310+
<InputVanilla id="vanilla-default" placeholder="Default input" />
311+
</Box>
312+
313+
<Box>
314+
<Label htmlFor="vanilla-large">Large</Label>
315+
<InputVanilla id="vanilla-large" size="large" placeholder="Large input" />
316+
</Box>
317+
318+
<Box>
319+
<Label htmlFor="vanilla-ghost">Ghost</Label>
320+
<InputVanilla id="vanilla-ghost" variant="ghost" placeholder="Ghost input" />
321+
</Box>
322+
323+
<Box>
324+
<Label htmlFor="vanilla-invalid">Invalid</Label>
325+
<InputVanilla id="vanilla-invalid" state="invalid" placeholder="Invalid input" />
326+
</Box>
327+
328+
<Box>
329+
<Label htmlFor="vanilla-disabled">Disabled</Label>
330+
<InputVanilla id="vanilla-disabled" disabled defaultValue="Disabled input" />
331+
</Box>
332+
333+
<Box>
334+
<Label htmlFor="vanilla-adornments">Adornments</Label>
335+
<InputVanilla
336+
id="vanilla-adornments"
337+
startAdornment={<MagnifyingGlassIcon />}
338+
endAdornment={<StyledEyeOpenIcon />}
339+
placeholder="With adornments"
340+
/>
341+
</Box>
342+
</Flex>
343+
</Flex>
344+
);
345+
};
346+
249347
export default Component;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Re-export from plain TypeScript file to avoid circular dependencies
2+
// The source of truth is Input.theme.ts
3+
export { inputDarkTheme, inputLightTheme } from './Input.theme';

components/Input/Input.theme.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import tinycolor from 'tinycolor2';
2+
3+
// Plain TypeScript color tokens (no vanilla-extract)
4+
// Using default blue primary color for static tokens
5+
export const inputLightTheme = {
6+
inputBg: 'hsl(240, 14.0%, 99.0%)', // $deepBlue1
7+
inputBorder: 'hsl(208, 9.0%, 73.0%)', // $grayBlue9
8+
inputFocusBg: tinycolor('black').setAlpha(0.15).toHslString(),
9+
inputFocusBorder: 'hsl(206, 100%, 50%)', // blue8
10+
inputHoverBg: 'hsla(0, 0%, 100%, 0.372)', // $whiteA9
11+
inputText: tinycolor('black').setAlpha(0.74).toHslString(),
12+
inputPlaceholder: 'hsla(0, 0%, 0%, 0.498)', // $blackA10
13+
inputDisabledText: tinycolor('black').setAlpha(0.35).toHslString(),
14+
inputInvalidBorder: 'hsl(358, 75%, 59%)', // $red9
15+
};
16+
17+
export const inputDarkTheme = {
18+
inputBg: 'hsl(209, 38.0%, 12.0%)', // $grayBlue7 dark
19+
inputBorder: 'hsl(208, 11.0%, 45.0%)', // $grayBlue9 dark
20+
inputFocusBg: tinycolor('black').setAlpha(0.15).toHslString(),
21+
inputFocusBorder: 'hsl(206, 100%, 70%)', // blue11
22+
inputHoverBg: 'hsla(0, 0%, 100%, 0.104)', // $whiteA4
23+
inputText: tinycolor('white').setAlpha(0.8).toHslString(),
24+
inputPlaceholder: 'hsla(0, 0%, 100%, 0.455)', // $whiteA10
25+
inputDisabledText: tinycolor('white').setAlpha(0.35).toHslString(),
26+
inputInvalidBorder: 'hsl(358, 75%, 59%)', // $red9
27+
};

0 commit comments

Comments
 (0)