diff --git a/CLAUDE.md b/CLAUDE.md index f4bcdbf0..62a4eae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Faency is a React component library and design system for Traefik Labs, built with React, TypeScript, Stitches CSS-in-JS, and Radix UI Primitives. It provides accessible, themed components with light/dark mode support. -**Migration Status**: Currently migrating from Stitches to vanilla-extract (Phase 2 complete). Most components use Stitches (`.tsx`), some have vanilla-extract versions (`.vanilla.tsx`). Prefer editing Stitches versions unless explicitly migrating. See `VANILLA_EXTRACT_MIGRATION.md` for migration status and `VANILLA_EXTRACT_DEVELOPER_GUIDE.md` for comprehensive migration instructions. +**Migration Status**: Currently migrating from Stitches to vanilla-extract (Phase 3 in progress). Most components use Stitches (`.tsx`), some have vanilla-extract versions (`.vanilla.tsx`). Prefer editing Stitches versions unless explicitly migrating. See `VANILLA_EXTRACT_MIGRATION.md` for migration status and `VANILLA_EXTRACT_DEVELOPER_GUIDE.md` for comprehensive migration instructions. ## Development Commands diff --git a/README.md b/README.md index 1d675c39..7d0c25e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is the React component library and design system for [Traefik Labs](https://traefik.io). -Built with React, Typescript, [Stitches](https://github.com/modulz/stitches) and [Radix UI Primitives](https://radix-ui.com/primitives/docs/overview/introduction). +Built with React, Typescript, [Stitches](https://github.com/modulz/stitches), [vanilla-extract](https://vanilla-extract.style/) (migration in progress), and [Radix UI Primitives](https://radix-ui.com/primitives/docs/overview/introduction). ## Demo (Storybook) @@ -46,17 +46,11 @@ const Container = styled(Flex, { const MyComponent = () => {children}; ``` -#### Using Vanilla Extract Components (New - Recommended) +#### Using Vanilla Extract Components (Migration in Progress) -For better performance with static CSS, use the new Vanilla Extract components: +For better performance with static CSS, you can use the new Vanilla Extract components (many components are available, more being migrated). -1. Import the CSS file in your app's entry point: - -```jsx -import '@traefiklabs/faency/dist/style.css'; -``` - -2. Wrap your app with the VanillaExtractThemeProvider: +Wrap your app with the VanillaExtractThemeProvider: ```jsx import { VanillaExtractThemeProvider } from '@traefiklabs/faency'; @@ -71,11 +65,11 @@ const App = () => ( ); ``` -> **Note**: Vanilla Extract components use static CSS generated at build time, providing better performance than runtime CSS-in-JS. Components with the `Vanilla` suffix (e.g., `BadgeVanilla`, `BoxVanilla`) require the CSS import above. +> **Note**: Vanilla Extract components use static CSS generated at build time, providing better performance than runtime CSS-in-JS. CSS is automatically included when you import components - no separate CSS import needed. Components with the `Vanilla` suffix (e.g., `BadgeVanilla`, `BoxVanilla`) are available. Not all components have Vanilla Extract versions yet - check the component documentation or use the Stitches version if a Vanilla version is not available. ## How to contribute -- Make sure you have Node 18+, or if you prefer, you can work in a Docker container: +- Make sure you have Node 20+, or if you prefer, you can work in a Docker container: ```sh docker run -it -v $(pwd):/usr/local/src/ -w /usr/local/src/ -p 3000:3000 node:latest bash diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ec196010..189cff71 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -63,13 +63,12 @@ function App() { export default App; ``` -#### Using Vanilla Extract Components (New - Recommended) +#### Using Vanilla Extract Components (Migration in Progress) -For Vanilla Extract components, import the CSS file and use the `VanillaExtractThemeProvider`: +For Vanilla Extract components, use the `VanillaExtractThemeProvider`: ```tsx import React from 'react'; -import '@traefiklabs/faency/dist/style.css'; // Required for Vanilla Extract components import { VanillaExtractThemeProvider } from '@traefiklabs/faency'; import { BoxVanilla, BadgeVanilla } from '@traefiklabs/faency'; @@ -86,21 +85,17 @@ function App() { export default App; ``` +> **Note**: CSS is automatically included when you import Vanilla Extract components - no separate CSS import needed. + ### Import Styles #### For Stitches Components (Legacy) Stitches components use runtime CSS-in-JS, so no separate CSS imports are needed. Styles are automatically injected when you use components. -#### For Vanilla Extract Components (New - Recommended) - -Vanilla Extract components require importing the static CSS file. Add this import to your application's entry point (e.g., `App.tsx` or `index.tsx`): - -```tsx -import '@traefiklabs/faency/dist/style.css'; -``` +#### For Vanilla Extract Components (Migration in Progress) -This CSS file contains all the styles for Vanilla Extract components (components with `Vanilla` suffix like `BadgeVanilla`, `BoxVanilla`, etc.). Without this import, these components will render as unstyled elements. +Vanilla Extract components include their CSS automatically when imported. No separate CSS import is required - styles are bundled with each component module. --- diff --git a/VANILLA_EXTRACT_DEVELOPER_GUIDE.md b/VANILLA_EXTRACT_DEVELOPER_GUIDE.md index cda067a1..3486440f 100644 --- a/VANILLA_EXTRACT_DEVELOPER_GUIDE.md +++ b/VANILLA_EXTRACT_DEVELOPER_GUIDE.md @@ -14,12 +14,11 @@ This guide explains how the new Vanilla Extract styling system works and provide **Reference:** -- [Code Examples](#code-examples) - [Common Patterns](#common-patterns) - [Troubleshooting](#troubleshooting) - [Migration Checklist](#migration-checklist) - [Best Practices](#best-practices) -- [Future Migration Phases](#reference-future-migration-phases) - Phase 2 & 3 (not yet actionable) +- [Future Migration Phases](#reference-future-migration-phases) --- @@ -138,16 +137,14 @@ When migrating a component, follow these strict naming conventions to maintain c #### File Naming -- **Styles**: `ComponentName.vanilla.css.ts` (e.g., [`Text.vanilla.css.ts`](components/Text/Text.vanilla.css.ts)) -- **Component**: `ComponentName.vanilla.tsx` (e.g., [`Text.vanilla.tsx`](components/Text/Text.vanilla.tsx)) -- **Tests**: `ComponentName.vanilla.test.tsx` (e.g., [`Text.vanilla.test.tsx`](components/Text/Text.vanilla.test.tsx)) -- **Theme (if needed)**: `ComponentName.theme.ts` + `ComponentName.theme.css.ts` (see Badge example) +- **Styles**: `ComponentName.vanilla.css.ts` +- **Component**: `ComponentName.vanilla.tsx` +- **Tests**: `ComponentName.vanilla.test.tsx` +- **Theme (if needed)**: `ComponentName.theme.ts` + `ComponentName.theme.css.ts` #### Component Naming -**Reference Implementation:** - -See [`components/Text/Text.vanilla.tsx`](components/Text/Text.vanilla.tsx) for the complete naming pattern: +The complete naming pattern (see [Text.vanilla.tsx](components/Text/Text.vanilla.tsx)): - Internal type: `TextComponent` (for typing the implementation) - Exported props type: `TextVanillaProps` (for consumers) @@ -156,7 +153,7 @@ See [`components/Text/Text.vanilla.tsx`](components/Text/Text.vanilla.tsx) for t **Key Points:** -- Use `Omit` not `NonNullable` (see Badge component) +- Use `NonNullable` for variant types - Internal implementation uses `ComponentImpl` suffix - Exported component name is `ComponentVanilla` - Exported props type is `ComponentVanillaProps` @@ -164,9 +161,7 @@ See [`components/Text/Text.vanilla.tsx`](components/Text/Text.vanilla.tsx) for t #### Exports in index.ts -**Reference:** - -See [`components/Text/index.ts`](components/Text/index.ts) for the export pattern: +Export pattern to avoid naming conflicts: ```typescript export * from './Text'; // Stitches version @@ -174,12 +169,6 @@ export type { TextVanillaProps } from './Text.vanilla'; export { TextVanilla } from './Text.vanilla'; ``` -**Why explicit exports?** - -- Avoids naming conflicts between Stitches and vanilla-extract versions -- Makes it clear which version is being used -- Prevents accidental type collisions - #### Recipe Naming Use camelCase + "Recipe" suffix in `.vanilla.css.ts` files: @@ -190,19 +179,11 @@ export const badgeRecipe = recipe({...}); export const buttonRecipe = recipe({...}); ``` -See [`components/Button/Button.vanilla.css.ts`](components/Button/Button.vanilla.css.ts) for examples. - ### Step-by-Step Guide #### 1. Create the Vanilla Extract Styles File -Create a new file `ComponentName.vanilla.css.ts` alongside your component. - -**Reference Implementation:** - -- Simple component: [`components/Box/Box.vanilla.css.ts`](components/Box/Box.vanilla.css.ts) -- Component with variants: [`components/Button/Button.vanilla.css.ts`](components/Button/Button.vanilla.css.ts) -- Component with recipes: [`components/Badge/Badge.vanilla.css.ts`](components/Badge/Badge.vanilla.css.ts) +Create `ComponentName.vanilla.css.ts` alongside your component. **Key patterns:** @@ -211,18 +192,11 @@ Create a new file `ComponentName.vanilla.css.ts` alongside your component. - Use `recipe()` for variant-based styles - Reference theme tokens for themeable values -#### 2. Create the React Component - -Create `ComponentName.vanilla.tsx`. - -**Reference Implementations:** +**Examples:** [Box.vanilla.css.ts](components/Box/Box.vanilla.css.ts) (simple), [Button.vanilla.css.ts](components/Button/Button.vanilla.css.ts) (variants), [Badge.vanilla.css.ts](components/Badge/Badge.vanilla.css.ts) (recipes) -- **Simple component**: [`components/Box/Box.vanilla.tsx`](components/Box/Box.vanilla.tsx) -- **Button with variants**: [`components/Button/Button.vanilla.tsx`](components/Button/Button.vanilla.tsx) -- **Polymorphic component**: [`components/Badge/Badge.vanilla.tsx`](components/Badge/Badge.vanilla.tsx) -- **Component with theme tokens**: [`components/Text/Text.vanilla.tsx`](components/Text/Text.vanilla.tsx) +#### 2. Create the React Component -**Required steps:** +Create `ComponentName.vanilla.tsx` with these required steps: 1. Import `CSSProps`, `processCSSProp` from `styles/cssProps` 2. Import `useVanillaExtractTheme` from `styles/themeContext` @@ -232,43 +206,47 @@ Create `ComponentName.vanilla.tsx`. 6. Merge styles with `assignInlineVars(vars)` 7. Set `displayName` to match component name -#### 3. Add Comparison Story - -**Required:** Every migrated component MUST have a Comparison story that shows both versions side-by-side. +**Examples:** [Box.vanilla.tsx](components/Box/Box.vanilla.tsx) (simple), [Button.vanilla.tsx](components/Button/Button.vanilla.tsx) (variants), [Badge.vanilla.tsx](components/Badge/Badge.vanilla.tsx) (polymorphic), [Text.vanilla.tsx](components/Text/Text.vanilla.tsx) (theme tokens) -**Reference Implementation:** +#### 3. Add Comparison Story -See [`components/Text/Text.stories.tsx`](components/Text/Text.stories.tsx) - Look for the `Comparison` export which shows Stitches vs Vanilla Extract side-by-side. +**Required:** Every migrated component MUST have a Comparison story showing both versions side-by-side. Use vanilla-extract layout components (BoxVanilla, FlexVanilla, H3Vanilla) to avoid mixing systems. **Pattern:** ```tsx -// Import vanilla layout components import { BoxVanilla } from '../Box/Box.vanilla'; import { FlexVanilla } from '../Flex/Flex.vanilla'; -import { H3 } from '../Heading'; +import { H3Vanilla } from '../Heading'; import { ComponentNameVanilla } from './ComponentName.vanilla'; export const Comparison: StoryFn = () => ( -

Stitches Version

- {/* Show all major variants */} + Stitches Version + + + Option 1 + Option 2 + +
-

Vanilla Extract Version

- {/* Mirror the exact same variants */} + Vanilla Extract Version + + + Option 1 + Option 2 + +
); ``` -**Why this matters:** +**Example:** See [Text.stories.tsx](components/Text/Text.stories.tsx) for a complete implementation with all variants. -- Visual verification that both versions render identically -- Easy to spot visual regressions -- Tests light/dark theme switching for both versions -- Documents all key variants in one place +**Benefits:** Visual verification of parity, easy regression spotting, theme switching testing, variant documentation. #### 4. Test Theme Switching @@ -288,52 +266,40 @@ After adding the Comparison story: #### 5. Create Tests for Vanilla Extract Component -**REQUIRED:** Every migrated component MUST have a corresponding `.vanilla.test.tsx` file with comprehensive tests. - -**Reference Implementation:** - -See [`components/Input/Input.vanilla.test.tsx`](components/Input/Input.vanilla.test.tsx) for the gold standard testing pattern. - -Other examples: - -- [`components/Button/Button.vanilla.test.tsx`](components/Button/Button.vanilla.test.tsx) -- [`components/Text/Text.vanilla.test.tsx`](components/Text/Text.vanilla.test.tsx) -- [`components/Label/Label.vanilla.test.tsx`](components/Label/Label.vanilla.test.tsx) +**REQUIRED:** Every migrated component MUST have a `.vanilla.test.tsx` file with comprehensive tests. **Required test coverage:** -- Basic rendering and element types -- Custom className support -- All variant props (size, weight, variant, etc.) -- Custom styling (CSS prop and style prop) -- Style merging behavior -- Polymorphic rendering (if applicable) -- Ref forwarding -- HTML attribute pass-through -- Accessibility testing with jest-axe -- Theme support (light/dark and different primary colors) +- ✅ Basic rendering and element type +- ✅ All variant props +- ✅ Custom className +- ✅ Style prop +- ✅ CSS prop (with token processing) +- ✅ Style + CSS prop merging +- ✅ Polymorphic rendering (if applicable) +- ✅ Ref forwarding +- ✅ HTML attribute pass-through +- ✅ Accessibility (axe violations with jest-axe) +- ✅ Light/dark theme switching **Important notes:** - Use `unmount()` in loops to prevent "multiple elements found" errors - Always wrap components in `VanillaExtractThemeProvider` - For Radix-based components, use the correct role (e.g., `role="radio"` for ButtonSwitch items) -- Test files should be named `ComponentName.vanilla.test.tsx` - -#### 6. Export the Component -Update the component's exports following the [Export Strategy](#export-and-build-strategy). +**Examples:** [Input.vanilla.test.tsx](components/Input/Input.vanilla.test.tsx) (gold standard), [Button.vanilla.test.tsx](components/Button/Button.vanilla.test.tsx), [Badge.vanilla.test.tsx](components/Badge/Badge.vanilla.test.tsx) -**Reference:** +#### 6. Export the Component -See [`components/Button/index.ts`](components/Button/index.ts) for the export pattern. +Update both the component's `index.ts` and main [index.ts](index.ts): ```tsx export { Button } from './Button'; // Stitches (existing) export { ButtonVanilla } from './Button.vanilla'; // Vanilla Extract (new) ``` -Also update the main [`index.ts`](index.ts). +See [Export and Build Strategy](#export-and-build-strategy) for more details. #### 7. Clean Up (After Full Migration) @@ -352,143 +318,76 @@ After successful migration and verification: ## Export and Build Strategy -### Overview - -We're migrating from Stitches to Vanilla Extract in three phases, maintaining backward compatibility throughout. The build system is already configured to handle both systems simultaneously. - -### What Developers Need to Know - **Current Phase: Side-by-Side Exports (Phase 1)** -We export both versions of migrated components with clear naming conventions: +We export both versions with clear naming conventions: -- Original Stitches components keep their current names (e.g., `Box`) -- New Vanilla Extract versions use a `*Vanilla` suffix (e.g., `BoxVanilla`) -- Both are exported and available to consumers +- Original Stitches: `Box`, `Button`, `Text` +- Vanilla Extract: `BoxVanilla`, `ButtonVanilla`, `TextVanilla` -This approach allows: +**Benefits:** Zero breaking changes, gradual opt-in, easy comparison, feature parity verification. -- Zero breaking changes for existing users -- Gradual opt-in for early adopters -- Easy visual comparison and testing in Storybook -- Time to verify feature parity before switching +**Trade-off:** Larger bundle size (~80KB) during transition. -Trade-off: Larger bundle size (~80KB total) during the transition period. +### Exporting a Migrated Component ---- +1. **Update component's index.ts:** -### Developer Workflow: Exporting a Migrated Component + ```typescript + export { Button } from './Button'; // Stitches (keep) + export { ButtonVanilla } from './Button.vanilla'; // Vanilla Extract (add) + ``` -When you migrate a component, follow this export pattern: - -#### 1. Update Component's Local Index - -See [`components/Button/index.ts`](components/Button/index.ts) for example: - -```typescript -export { Button } from './Button'; // Stitches (keep existing) -export { ButtonVanilla } from './Button.vanilla'; // Vanilla Extract (add new) -``` +2. **Update main [index.ts](index.ts)** to export both versions -#### 2. Update Main Library Index +3. **Add Comparison story** (see [Step 3](#3-add-comparison-story)) -Update [`index.ts`](index.ts) to export both versions. +4. **Document in CHANGELOG.md:** + ```markdown + ### Added -#### 3. Add Comparison Stories + - `ButtonVanilla`: Vanilla Extract version of Button component + ``` -See Step 3 in the migration process above. - -#### 4. Document the Addition - -Add an entry to `CHANGELOG.md` under "Added": - -```markdown -### Added - -- `ButtonVanilla`: Vanilla Extract version of Button component -``` - -**Important**: No version bump needed during Phase 1 - this is backward compatible. +**Note:** No version bump needed during Phase 1 - this is backward compatible. --- ## Migrating Component-Specific Theme Tokens -Some components have their own theme tokens (e.g., `Badge.themes.ts`). Here's how to migrate them. - -### The New Approach - -In Vanilla Extract, component theme tokens are **co-located** with the component and merged into the global theme system while **preserving the original token names** to avoid breaking changes. - -**⚠️ IMPORTANT: Preserve Token Names** - -When migrating component-specific theme tokens, you **MUST preserve the exact same token names** from the Stitches version to avoid breaking changes. +**⚠️ IMPORTANT:** Preserve the exact same token names from the Stitches version to avoid breaking changes. -### Reference Implementations +### Approach -**Simple theme tokens:** +Component theme tokens are co-located with the component and merged into the global theme system. -See [`components/Badge/Badge.theme.css.ts`](components/Badge/Badge.theme.css.ts) - Tokens without runtime color references. +**Simple tokens** (no runtime color references): Create `ComponentName.theme.css.ts` - see [Badge.theme.css.ts](components/Badge/Badge.theme.css.ts) -**Complex theme tokens with runtime colors:** +**Complex tokens** (with runtime color references): Create two files to avoid circular dependencies: -See [`components/Text/Text.theme.ts`](components/Text/Text.theme.ts) and [`components/Text/Text.theme.css.ts`](components/Text/Text.theme.css.ts) - Pattern for tokens that reference other theme colors. +- `ComponentName.theme.ts` (plain TypeScript) - see [Text.theme.ts](components/Text/Text.theme.ts) +- `ComponentName.theme.css.ts` (re-export wrapper) - see [Text.theme.css.ts](components/Text/Text.theme.css.ts) -### Step-by-Step Process +### Steps -#### 1. Create Component Theme File +1. **Create component theme file(s)** (see examples above) -**For simple tokens** (no runtime color references): +2. **Add to [styles/tokens.css.ts](styles/tokens.css.ts)** in the `colors` object (not nested) -Create `ComponentName.theme.css.ts` - see [`components/Badge/Badge.theme.css.ts`](components/Badge/Badge.theme.css.ts) +3. **Merge into [styles/themes.css.ts](styles/themes.css.ts):** -**For complex tokens** (with runtime color references): + ```typescript + import { badgeLightTheme, badgeDarkTheme } from '../components/Badge/Badge.theme.css'; -Create two files to avoid circular dependencies: + const lightSemanticColors = { + // ... other colors ... + ...badgeLightTheme, // Merge component theme + }; + ``` -- `ComponentName.theme.ts` (plain TypeScript) - see [`components/Text/Text.theme.ts`](components/Text/Text.theme.ts) -- `ComponentName.theme.css.ts` (re-export wrapper) - see [`components/Text/Text.theme.css.ts`](components/Text/Text.theme.css.ts) +4. **Use in component styles:** Reference tokens from `tokens.colors` in your `.vanilla.css.ts` file -#### 2. Add to Global Theme Contract - -Add the tokens to [`styles/tokens.css.ts`](styles/tokens.css.ts) in the `colors` object (not nested). - -#### 3. Merge into Global Theme Implementations - -See [`styles/themes.css.ts`](styles/themes.css.ts) for how component themes are merged: - -```typescript -import { badgeLightTheme, badgeDarkTheme } from '../components/Badge/Badge.theme.css'; - -const lightSemanticColors = { - // ... other colors ... - ...badgeLightTheme, // Merge component theme -}; -``` - -For runtime color overrides, see the Text component section in [`styles/themes.css.ts`](styles/themes.css.ts). - -#### 4. Use in Component Styles - -Reference tokens from `tokens.colors` in your component's `.vanilla.css.ts` file. - -See [`components/Badge/Badge.vanilla.css.ts`](components/Badge/Badge.vanilla.css.ts) for usage examples. - -#### 5. Clean Up - -After migration, delete the old `ComponentName.themes.ts` file. - ---- - -## Code Examples - -See the step-by-step migration guide above for file references. Key reference implementations: - -- **Simple component**: [`components/Box/Box.vanilla.tsx`](components/Box/Box.vanilla.tsx) -- **Component with variants**: [`components/Badge/Badge.vanilla.tsx`](components/Badge/Badge.vanilla.tsx) -- **Component with theme tokens**: [`components/Text/Text.vanilla.tsx`](components/Text/Text.vanilla.tsx) -- **Polymorphic component**: [`components/Badge/Badge.vanilla.tsx`](components/Badge/Badge.vanilla.tsx) -- **Component with tests**: [`components/Input/Input.vanilla.test.tsx`](components/Input/Input.vanilla.test.tsx) +5. **Clean up:** Delete old `ComponentName.themes.ts` file --- @@ -496,7 +395,7 @@ See the step-by-step migration guide above for file references. Key reference im ### Using Design Tokens -Always use tokens from the theme contract for themeable values. See [`styles/tokens.css.ts`](styles/tokens.css.ts) for available tokens. +Always use tokens from [styles/tokens.css.ts](styles/tokens.css.ts) for themeable values: ```tsx // ✅ Good - uses theme tokens @@ -508,16 +407,10 @@ backgroundColor: '#ffffff', ### Migrating from `asChild` to Polymorphic `as` -Replace `asChild` pattern with polymorphic `as` prop. - -**Reference Implementation:** - -See [`components/Badge/Badge.vanilla.tsx`](components/Badge/Badge.vanilla.tsx) for the complete polymorphic pattern. - -**Migration steps:** +Replace `asChild` pattern with polymorphic `as` prop (see [Badge.vanilla.tsx](components/Badge/Badge.vanilla.tsx)): 1. Remove `@radix-ui/react-slot` dependency -2. Import polymorphic types from [`styles/polymorphic.ts`](styles/polymorphic.ts) +2. Import polymorphic types from [styles/polymorphic.ts](styles/polymorphic.ts) 3. Replace `asChild?: boolean` with `as` prop using generic type parameter 4. Use `PolymorphicComponentProps` for props type 5. Cast implementation to `PolymorphicComponent` type @@ -530,19 +423,13 @@ import { useVanillaExtractTheme } from '../../styles/themeContext'; const { colors, resolvedTheme } = useVanillaExtractTheme(); ``` -See any vanilla component for usage examples. - ### Using Shared Text Variants (DRY Pattern) -For Text-based components needing identical variant behavior, import from [`styles/textVariants.css.ts`](styles/textVariants.css.ts). - -**Reference Implementations:** +For Text-based components needing identical variant behavior, import from [styles/textVariants.css.ts](styles/textVariants.css.ts). -- [`components/Text/Text.vanilla.css.ts`](components/Text/Text.vanilla.css.ts) -- [`components/Label/Label.vanilla.css.ts`](components/Label/Label.vanilla.css.ts) -- [`components/Blockquote/Blockquote.vanilla.css.ts`](components/Blockquote/Blockquote.vanilla.css.ts) +**Benefits:** Single source of truth, ~70% code reduction, consistency. -**Benefits:** Single source of truth, ~70% code reduction, consistency +**Examples:** [Text.vanilla.css.ts](components/Text/Text.vanilla.css.ts), [Label.vanilla.css.ts](components/Label/Label.vanilla.css.ts), [Blockquote.vanilla.css.ts](components/Blockquote/Blockquote.vanilla.css.ts) **Don't use** if component needs custom variant behavior. @@ -552,11 +439,11 @@ For Text-based components needing identical variant behavior, import from [`styl ### Theme Not Applied -Use theme tokens instead of hardcoded values. See [`styles/tokens.css.ts`](styles/tokens.css.ts) for available tokens. +Use theme tokens from [styles/tokens.css.ts](styles/tokens.css.ts) instead of hardcoded values. ### CSS Prop Not Working -Ensure you follow the pattern in any vanilla component (e.g., [`components/Button/Button.vanilla.tsx`](components/Button/Button.vanilla.tsx)): +Follow the pattern in vanilla components (see [Button.vanilla.tsx](components/Button/Button.vanilla.tsx)): 1. Add `CSSProps` to component interface 2. Import and use `processCSSProp(css, colors)` from `useVanillaExtractTheme()` @@ -565,37 +452,55 @@ Ensure you follow the pattern in any vanilla component (e.g., [`components/Butto ### TypeScript Errors -#### Problem: RecipeVariants returns undefined - -Always wrap `RecipeVariants` with `NonNullable`. See any vanilla component for the pattern. +#### RecipeVariants returns undefined -#### Problem: Stitches components in vanilla components cause type errors +Always wrap `RecipeVariants` with `NonNullable`: -**CRITICAL RULE:** Never mix Stitches and vanilla-extract components. Always use vanilla-extract versions inside vanilla components. +```tsx +// ❌ Bad - may return undefined +type MyComponentVariants = RecipeVariants; -See [`components/TextField/TextField.vanilla.tsx`](components/TextField/TextField.vanilla.tsx) for an example of using vanilla components. +// ✅ Good - guaranteed to have variant properties +type MyComponentVariants = NonNullable>; +``` -#### Problem: Props typed as `CSSProps` require `as any` in tests +#### Invalid selector error for child elements -**Root cause:** The prop is typed as `CSSProps` but should be `CSSProps['css']`. +**Problem:** `Invalid selector: &:not(:empty) > *` - Vanilla-extract only allows `&` with pseudo-classes/elements in `style()`. -**Reference:** See [`components/Textarea/Textarea.vanilla.tsx`](components/Textarea/Textarea.vanilla.tsx) for correct typing of additional CSS props. +**Solution:** Use `globalStyle()` for child selectors: -**Understanding CSSProps property names:** +```tsx +// ❌ Bad - causes error +const skeleton = style({ + selectors: { '&:not(:empty) > *': { visibility: 'hidden' } }, +}); -The [`styles/cssProps.ts`](styles/cssProps.ts) interface accepts both abbreviated and full property names. +// ✅ Good - use globalStyle +const skeleton = style({ + selectors: { '&:not(:empty)': { maxWidth: 'fit-content' } }, +}); -```tsx -// Both work identically: - // Abbreviated - // Full names +globalStyle(`${skeleton}:not(:empty) > *`, { visibility: 'hidden' }); ``` +**Rule:** Use `globalStyle()` for: `& > div`, `& h1`, `& + &` (children/siblings). Use `style()` for: `&:hover`, `&::before` (self-targeting). + +#### Mixing Stitches and vanilla-extract components + +**CRITICAL:** Never mix systems. Always use vanilla-extract versions inside vanilla components (see [TextField.vanilla.tsx](components/TextField/TextField.vanilla.tsx)). + +#### Props typed as `CSSProps` require `as any` in tests + +**Root cause:** Should be `CSSProps['css']` not `CSSProps`. See [Textarea.vanilla.tsx](components/Textarea/Textarea.vanilla.tsx) for correct typing. + +**Note:** `CSSProps` accepts both abbreviated (`p`, `m`) and full names (`padding`, `margin`). + ### Build or Storybook Issues -- **Build errors**: Check [`vite.config.ts`](vite.config.ts) has `vanillaExtractPlugin()` -- **Theme not switching**: Verify [`.storybook/preview.jsx`](.storybook/preview.jsx) has `VanillaExtractThemeProvider` -- **Color mismatch**: Compare tokens used in both versions, check Comparison story +- **Build errors**: Check [vite.config.ts](vite.config.ts) has `vanillaExtractPlugin()` +- **Theme not switching**: Verify [.storybook/preview.jsx](.storybook/preview.jsx) has `VanillaExtractThemeProvider` +- **Color mismatch**: Compare tokens, check Comparison story --- @@ -657,44 +562,30 @@ Use this checklist for each component migration: - Test both light and dark themes - Migrate one component at a time -### Critical Pattern: Include All Base Styles in Recipes +### Critical: Include All Base Styles in Recipes **⚠️ IMPORTANT:** When recipes have variants, include ALL base styles (font sizes, dimensions, etc.) in the recipe's base array, not in separate style constants. -**Why:** If styles are split and components conditionally apply recipe classes, base styles will be missing when variants are used. - -**Reference:** - -See [`components/Heading/Heading.vanilla.css.ts`](components/Heading/Heading.vanilla.css.ts) for the correct pattern - all styles are included in the recipe base. +**Why:** Split styles with conditional recipe classes will miss base styles when variants are used. -**Rule:** Put ALL styling in recipe base if component has variants. Test variant combinations to verify. +**Rule:** Put ALL styling in recipe base if component has variants (see [Heading.vanilla.css.ts](components/Heading/Heading.vanilla.css.ts)). --- ## Reference: Future Migration Phases -**Note**: These phases are not currently actionable. Continue using Phase 1 (side-by-side exports). - -### Phase 2: Make Vanilla Extract Default (v2.0.0) - -Swap exports, deprecate Stitches versions. Breaking change for consumers. +**Note**: Not currently actionable. Continue using Phase 1 (side-by-side exports). -### Phase 3: Remove Stitches (v3.0.0) - -Delete Stitches files, remove `.vanilla` suffix from filenames. ~40% smaller bundle. +- **Phase 2 (v2.0.0):** Make Vanilla Extract default, deprecate Stitches (breaking change) +- **Phase 3 (v3.0.0):** Remove Stitches files, remove `.vanilla` suffix (~40% smaller bundle) --- ## Reference: Build Configuration -The build system is already configured in [`vite.config.ts`](vite.config.ts) to support both Stitches and Vanilla Extract simultaneously. No action needed. - -**Key Settings:** +Build system already configured in [vite.config.ts](vite.config.ts) - no action needed. -- `preserveModules: true` - Maintains module structure for tree-shaking -- Both `.tsx` and `.css.ts` files are processed -- Outputs ES modules (ESM) and CommonJS (CJS) builds -- Static CSS files generated at build time (not runtime) +**Key settings:** `preserveModules: true` (tree-shaking), processes `.tsx` and `.css.ts`, outputs ESM/CJS, static CSS at build time. --- diff --git a/VANILLA_EXTRACT_MIGRATION.md b/VANILLA_EXTRACT_MIGRATION.md index 5b1775eb..c80ca210 100644 --- a/VANILLA_EXTRACT_MIGRATION.md +++ b/VANILLA_EXTRACT_MIGRATION.md @@ -18,7 +18,7 @@ This document outlines the progressive migration strategy from Stitches to vanil - ✅ Storybook theme integration - ✅ Developer migration guide -⏳ **Phase 3 Planned**: Component-by-Component Migration +🚧 **Phase 3 In Progress**: Component-by-Component Migration ⏳ **Phase 4 Planned**: Remove Stitches ## Architecture Overview @@ -88,36 +88,7 @@ styles/ ## Component Migration Pattern -### Before (Stitches) - -```tsx -import { styled } from '../../stitches.config'; - -export const Box = styled('div', { - boxSizing: 'border-box', -}); - -// Usage with CSS prop - -``` - -### After (Vanilla-Extract) - -```tsx -import { style } from '@vanilla-extract/css'; -import { recipe } from '@vanilla-extract/recipes'; - -export const box = style({ - boxSizing: 'border-box', -}); - -export const boxRecipe = recipe({ - base: box, - variants: { - // Add variants as needed - }, -}); -``` +See [VANILLA_EXTRACT_DEVELOPER_GUIDE.md](./VANILLA_EXTRACT_DEVELOPER_GUIDE.md) for detailed migration steps and patterns. ## Key Considerations @@ -152,10 +123,11 @@ export const boxRecipe = recipe({ ## Next Steps 1. ✅ **Complete Phase 2**: Coexistence confirmed working ✓ -2. **Start Phase 3**: Begin migrating simple components (Text, Flex, Grid) +2. 🚧 **Phase 3 In Progress**: Migrating components - Follow the [Developer Guide](./VANILLA_EXTRACT_DEVELOPER_GUIDE.md) for migration steps -3. **Validate approach**: Ensure migrated components work exactly like originals -4. **Scale migration**: Use learnings to migrate more complex components + - Validate migrated components work exactly like originals +3. **Continue migration**: Scale to more complex components +4. **Phase 4**: Remove Stitches after all components migrated ## Benefits After Migration diff --git a/components/AccessibleIcon/AccessibleIcon.stories.tsx b/components/AccessibleIcon/AccessibleIcon.stories.tsx index 9ca07c0f..6b8f8548 100644 --- a/components/AccessibleIcon/AccessibleIcon.stories.tsx +++ b/components/AccessibleIcon/AccessibleIcon.stories.tsx @@ -3,9 +3,13 @@ import * as Icons from '@radix-ui/react-icons'; import { Meta, StoryFn } from '@storybook/react-vite'; import React from 'react'; +import { BoxVanilla } from '../Box/Box.vanilla'; import { Flex } from '../Flex'; +import { FlexVanilla } from '../Flex/Flex.vanilla'; +import { H3Vanilla } from '../Heading'; import { IconButton } from '../IconButton'; import { AccessibleIcon } from './AccessibleIcon'; +import { AccessibleIconVanilla } from './AccessibleIcon.vanilla'; const Component: Meta = { title: 'Components/AccessibleIcon', @@ -29,4 +33,66 @@ export const Basic: StoryFn = () => ( ); +export const Comparison: StoryFn = () => ( + + + Stitches Version + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Vanilla Extract Version + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + export default Component; diff --git a/components/AccessibleIcon/AccessibleIcon.vanilla.test.tsx b/components/AccessibleIcon/AccessibleIcon.vanilla.test.tsx new file mode 100644 index 00000000..bab44a79 --- /dev/null +++ b/components/AccessibleIcon/AccessibleIcon.vanilla.test.tsx @@ -0,0 +1,127 @@ +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import { VanillaExtractThemeProvider } from '../../styles/themeContext'; +import { AccessibleIconVanilla } from './AccessibleIcon.vanilla'; + +describe('AccessibleIconVanilla', () => { + const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); + }; + + const TestIcon = () => ( + + + + ); + + it('should render with label', () => { + const { container } = renderWithTheme( + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should provide accessible label via visually hidden text', () => { + const { container } = renderWithTheme( + + + , + ); + + // The label should be present in the DOM for screen readers, but visually hidden + expect(container.textContent).toContain('Notification Bell'); + }); + + it('should render icon with accessible label', () => { + const { container } = renderWithTheme( + + + , + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(container.textContent).toContain('User Avatar'); + }); + + it('should render child icon element', () => { + const { container } = renderWithTheme( + + + , + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should preserve child element classes', () => { + const { container } = renderWithTheme( + +
Icon
+
, + ); + + const iconElement = container.querySelector('.custom-icon'); + expect(iconElement).toBeInTheDocument(); + expect(container.textContent).toContain('Search'); + }); + + it('should have no accessibility violations', async () => { + const { container } = renderWithTheme( + + + , + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should work with light theme', () => { + const { container } = render( + + + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should work with dark theme', () => { + const { container } = render( + + + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should work with complex icon components', () => { + const ComplexIcon = () => ( + + + + + ); + + const { container } = renderWithTheme( + + + , + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(container.textContent).toContain('Check Icon'); + }); +}); diff --git a/components/AccessibleIcon/AccessibleIcon.vanilla.tsx b/components/AccessibleIcon/AccessibleIcon.vanilla.tsx new file mode 100644 index 00000000..b5d38e5e --- /dev/null +++ b/components/AccessibleIcon/AccessibleIcon.vanilla.tsx @@ -0,0 +1,3 @@ +import * as AccessibleIconPrimitive from '@radix-ui/react-accessible-icon'; + +export const AccessibleIconVanilla = AccessibleIconPrimitive.Root; diff --git a/components/AccessibleIcon/index.tsx b/components/AccessibleIcon/index.tsx index 43ff6082..e47cf6b6 100644 --- a/components/AccessibleIcon/index.tsx +++ b/components/AccessibleIcon/index.tsx @@ -1 +1,2 @@ export * from './AccessibleIcon'; +export { AccessibleIconVanilla } from './AccessibleIcon.vanilla'; diff --git a/components/Image/Image.stories.tsx b/components/Image/Image.stories.tsx index 569ece5c..c84ba3fc 100644 --- a/components/Image/Image.stories.tsx +++ b/components/Image/Image.stories.tsx @@ -2,7 +2,11 @@ import { Meta, StoryFn } from '@storybook/react-vite'; import React from 'react'; +import { BoxVanilla } from '../Box/Box.vanilla'; +import { FlexVanilla } from '../Flex/Flex.vanilla'; +import { H3Vanilla } from '../Heading'; import { Image } from './Image'; +import { ImageVanilla } from './Image.vanilla'; const Component: Meta = { title: 'Components/Image', @@ -23,4 +27,42 @@ Large.args = { src: 'https://picsum.photos/2000/3000', }; +export const Comparison: StoryFn = () => ( + + + Stitches Version + + + Basic example + Square example + + + Large example + + + + + + Vanilla Extract Version + + + + + + + + + + + +); + export default Component; diff --git a/components/Image/Image.vanilla.css.ts b/components/Image/Image.vanilla.css.ts new file mode 100644 index 00000000..1fbfbb8b --- /dev/null +++ b/components/Image/Image.vanilla.css.ts @@ -0,0 +1,6 @@ +import { style } from '@vanilla-extract/css'; + +export const image = style({ + verticalAlign: 'middle', + maxWidth: '100%', +}); diff --git a/components/Image/Image.vanilla.test.tsx b/components/Image/Image.vanilla.test.tsx new file mode 100644 index 00000000..dbdb239d --- /dev/null +++ b/components/Image/Image.vanilla.test.tsx @@ -0,0 +1,198 @@ +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import { VanillaExtractThemeProvider } from '../../styles/themeContext'; +import { ImageVanilla } from './Image.vanilla'; + +describe('ImageVanilla', () => { + const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); + }; + + it('should render as img element by default', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild; + + expect(element?.nodeName).toBe('IMG'); + expect(element).toHaveAttribute('src', 'https://example.com/image.jpg'); + expect(element).toHaveAttribute('alt', 'Test image'); + }); + + it('should render with custom className', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.className).toContain('custom-class'); + }); + + it('should apply custom styles via style prop', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.style.border).toBe('1px solid red'); + expect(element.style.padding).toBe('10px'); + }); + + it('should apply CSS prop styles', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.style.padding).toBe('20px'); + expect(element.style.margin).toBe('8px'); + }); + + it('should merge style and css props correctly', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.style.border).toBe('2px solid blue'); + expect(element.style.padding).toBe('30px'); // style overrides css + expect(element.style.margin).toBe('8px'); // from css + }); + + it('should forward ref correctly', () => { + const ref = { current: null }; + renderWithTheme(); + + expect(ref.current).toBeInstanceOf(HTMLImageElement); + }); + + it('should pass through HTML attributes', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.getAttribute('data-testid')).toBe('test-image'); + expect(element.getAttribute('loading')).toBe('lazy'); + expect(element.getAttribute('width')).toBe('300'); + expect(element.getAttribute('height')).toBe('200'); + }); + + it('should have no accessibility violations with alt text', async () => { + const { container } = renderWithTheme( + , + ); + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); + + it('should work with light theme', () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should work with dark theme', () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply base styles (verticalAlign middle and maxWidth 100%)', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + // The styles are applied via className, so we check the element has the class + expect(element.className).toBeTruthy(); + }); + + it('should handle css prop with width override', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.style.width).toBe('200px'); + }); + + it('should render as different element when using as prop', () => { + const { container } = renderWithTheme(); + const element = container.firstChild; + + expect(element?.nodeName).toBe('DIV'); + }); + + it('should support polymorphic rendering with different elements', () => { + const elements = ['img', 'div', 'span', 'picture'] as const; + + elements.forEach((element) => { + const { container, unmount } = renderWithTheme( + , + ); + const renderedElement = container.firstChild; + + expect(renderedElement?.nodeName).toBe(element.toUpperCase()); + unmount(); + }); + }); + + it('should forward ref correctly with polymorphic as prop', () => { + const ref = { current: null }; + renderWithTheme(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('should apply styles with polymorphic rendering', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element.nodeName).toBe('DIV'); + expect(element.style.padding).toBe('20px'); + }); + + it('should maintain image attributes when rendered as img with polymorphic typing', () => { + const { container } = renderWithTheme( + , + ); + const element = container.firstChild; + + expect(element?.nodeName).toBe('IMG'); + expect(element).toHaveAttribute('src', 'https://example.com/image.jpg'); + expect(element).toHaveAttribute('alt', 'Polymorphic image'); + }); +}); diff --git a/components/Image/Image.vanilla.tsx b/components/Image/Image.vanilla.tsx new file mode 100644 index 00000000..acc9c794 --- /dev/null +++ b/components/Image/Image.vanilla.tsx @@ -0,0 +1,48 @@ +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { ElementType, forwardRef } from 'react'; + +import { CSSProps, processCSSProp } from '../../styles/cssProps'; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicRef, +} from '../../styles/polymorphic'; +import { useVanillaExtractTheme } from '../../styles/themeContext'; +import { image } from './Image.vanilla.css'; + +export type ImageVanillaProps = PolymorphicComponentProps< + C, + CSSProps +>; + +type ImageVanillaComponent = PolymorphicComponent<'img', ImageVanillaProps>; + +const ImageVanillaImpl = forwardRef( + ( + { as, className, css, style, ...props }: ImageVanillaProps, + ref?: PolymorphicRef, + ) => { + const Component = as || 'img'; + const { colors } = useVanillaExtractTheme(); + const { style: cssStyles, vars } = processCSSProp(css, colors); + + const mergedStyles = { + ...cssStyles, + ...style, + ...assignInlineVars(vars), + }; + + return ( + + ); + }, +); + +ImageVanillaImpl.displayName = 'ImageVanilla'; + +export const ImageVanilla = ImageVanillaImpl as ImageVanillaComponent; diff --git a/components/Image/index.ts b/components/Image/index.ts index 4bbac901..c854e3eb 100644 --- a/components/Image/index.ts +++ b/components/Image/index.ts @@ -1 +1,3 @@ -export * from './Image'; +export { Image } from './Image'; +export type { ImageVanillaProps } from './Image.vanilla'; +export { ImageVanilla } from './Image.vanilla'; diff --git a/components/Skeleton/Skeleton.stories.tsx b/components/Skeleton/Skeleton.stories.tsx index 8820b3d2..6b9c3df0 100644 --- a/components/Skeleton/Skeleton.stories.tsx +++ b/components/Skeleton/Skeleton.stories.tsx @@ -7,12 +7,16 @@ import { modifyVariantsForStory } from '../../utils/modifyVariantsForStory'; import { Avatar } from '../Avatar'; import { Badge as FaencyBadge } from '../Badge'; import { Box } from '../Box'; +import { BoxVanilla } from '../Box/Box.vanilla'; import { Bubble } from '../Bubble'; import { Button as FaencyButton } from '../Button'; import { Flex } from '../Flex'; +import { FlexVanilla } from '../Flex/Flex.vanilla'; import { H1, H2, H3, H4, H5, H6 } from '../Heading'; +import { H3Vanilla } from '../Heading/Heading.vanilla'; import { Text as FaencyText } from '../Text'; import { Skeleton, SkeletonProps, SkeletonVariants } from './Skeleton'; +import { SkeletonVanilla } from './Skeleton.vanilla'; const BaseSkeleton = (props: SkeletonProps): JSX.Element => ; const SkeletonForStory = modifyVariantsForStory(BaseSkeleton); @@ -204,4 +208,50 @@ export const Customs: StoryFn = () => ( ); +export const Comparison: StoryFn = () => ( + + + Stitches Version + + + + + + + + + + + + + + + + + + + + + Vanilla Extract Version + + + + + + + + + + + + + + + + + + + +); + export default Component; diff --git a/components/Skeleton/Skeleton.theme.css.ts b/components/Skeleton/Skeleton.theme.css.ts new file mode 100644 index 00000000..aa77f8bb --- /dev/null +++ b/components/Skeleton/Skeleton.theme.css.ts @@ -0,0 +1,3 @@ +// Re-export from plain TypeScript file to avoid circular dependencies +// The source of truth is Skeleton.theme.ts +export { skeletonDarkTheme, skeletonLightTheme } from './Skeleton.theme'; diff --git a/components/Skeleton/Skeleton.theme.ts b/components/Skeleton/Skeleton.theme.ts new file mode 100644 index 00000000..e015d37d --- /dev/null +++ b/components/Skeleton/Skeleton.theme.ts @@ -0,0 +1,12 @@ +// Plain TypeScript color tokens (no vanilla-extract) +// Re-exported by Skeleton.theme.css.ts to avoid circular dependencies + +export const skeletonLightTheme = { + skeletonBackground: 'var(--colors-slate4)', + skeletonAnimation: 'var(--colors-slate6)', +}; + +export const skeletonDarkTheme = { + skeletonBackground: 'var(--colors-deepBlue3)', + skeletonAnimation: 'var(--colors-deepBlue4)', +}; diff --git a/components/Skeleton/Skeleton.vanilla.css.ts b/components/Skeleton/Skeleton.vanilla.css.ts new file mode 100644 index 00000000..615167cb --- /dev/null +++ b/components/Skeleton/Skeleton.vanilla.css.ts @@ -0,0 +1,80 @@ +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +import { tokens } from '../../styles/tokens.css'; + +const pulse = keyframes({ + '0%': { opacity: 0 }, + '100%': { opacity: 1 }, +}); + +const skeletonBase = style({ + backgroundColor: tokens.colors.skeletonBackground, + position: 'relative', + overflow: 'hidden', + borderRadius: '3px', + height: 'auto', + width: 'auto', + + selectors: { + '&:not(:empty)': { + maxWidth: 'fit-content', + }, + }, + + '::after': { + animationName: pulse, + animationDuration: '500ms', + animationDirection: 'alternate', + animationIterationCount: 'infinite', + animationTimingFunction: 'ease-in-out', + backgroundColor: tokens.colors.skeletonAnimation, + borderRadius: 'inherit', + bottom: 0, + content: '""', + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, +}); + +// Global styles for child elements inside skeleton +globalStyle(`${skeletonBase}:not(:empty) > *`, { + visibility: 'hidden', + display: 'block', +}); + +export const skeletonRecipe = recipe({ + base: skeletonBase, + + variants: { + variant: { + square: { + borderRadius: tokens.radii['2'], + }, + circle: { + borderRadius: tokens.radii.round, + }, + badge: { + borderRadius: tokens.radii.pill, + display: 'inline-flex', + }, + button: { + borderRadius: tokens.radii['3'], + display: 'inline-flex', + }, + text: { + selectors: { + '&:empty::before': { + content: '"\\00a0"', + }, + }, + }, + }, + }, + + defaultVariants: { + variant: 'text', + }, +}); diff --git a/components/Skeleton/Skeleton.vanilla.test.tsx b/components/Skeleton/Skeleton.vanilla.test.tsx new file mode 100644 index 00000000..272550b3 --- /dev/null +++ b/components/Skeleton/Skeleton.vanilla.test.tsx @@ -0,0 +1,194 @@ +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import { VanillaExtractThemeProvider } from '../../styles/themeContext'; +import { SkeletonVanilla } from './Skeleton.vanilla'; + +describe('SkeletonVanilla', () => { + const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); + }; + + it('should render as div by default', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild; + + expect(skeleton?.nodeName).toBe('DIV'); + }); + + it('should render with text variant by default', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply variant prop - square', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply variant prop - circle', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply variant prop - badge', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply variant prop - button', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should apply variant prop - text', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should render with children', () => { + const { container } = renderWithTheme( + +
Child content
+
, + ); + const skeleton = container.firstChild; + + expect(skeleton).toBeInTheDocument(); + expect(skeleton).toContainHTML('
Child content
'); + }); + + it('should render with custom className', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.className).toContain('custom-skeleton'); + }); + + it('should apply custom styles via style prop', () => { + const { container } = renderWithTheme( + , + ); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.style.width).toBe('100px'); + expect(skeleton.style.height).toBe('50px'); + }); + + it('should apply CSS prop styles', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.style.width).toBe('32px'); + expect(skeleton.style.height).toBe('24px'); + }); + + it('should apply CSS prop with size shorthand', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.style.width).toBe('32px'); + expect(skeleton.style.height).toBe('32px'); + }); + + it('should merge style and css props correctly', () => { + const { container } = renderWithTheme( + , + ); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.style.backgroundColor).toBe('red'); + expect(skeleton.style.width).toBe('100px'); + expect(skeleton.style.height).toBe('24px'); + }); + + it('should forward ref correctly', () => { + const ref = { current: null }; + renderWithTheme(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('should pass through HTML attributes', () => { + const { container } = renderWithTheme( + , + ); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton.getAttribute('data-testid')).toBe('test-skeleton'); + expect(skeleton.getAttribute('aria-label')).toBe('Loading'); + }); + + it('should have no accessibility violations', async () => { + const { container } = renderWithTheme(); + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); + + it('should work with light theme', () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should work with dark theme', () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should render all variants without errors', () => { + const variants = ['square', 'circle', 'badge', 'button', 'text'] as const; + + variants.forEach((variant) => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeInTheDocument(); + }); + }); + + it('should render as different element when using as prop', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild; + + expect(skeleton?.nodeName).toBe('SPAN'); + }); + + it('should support polymorphic rendering with different elements', () => { + const elements = ['div', 'span', 'section', 'article'] as const; + + elements.forEach((element) => { + const { container, unmount } = renderWithTheme(); + const skeleton = container.firstChild; + + expect(skeleton?.nodeName).toBe(element.toUpperCase()); + unmount(); + }); + }); + + it('should forward ref correctly with polymorphic as prop', () => { + const ref = { current: null }; + renderWithTheme(); + + expect(ref.current).toBeInstanceOf(HTMLSpanElement); + }); + + it('should apply variant styles with polymorphic rendering', () => { + const { container } = renderWithTheme(); + const skeleton = container.firstChild; + + expect(skeleton?.nodeName).toBe('SECTION'); + expect(skeleton).toBeInTheDocument(); + }); +}); diff --git a/components/Skeleton/Skeleton.vanilla.tsx b/components/Skeleton/Skeleton.vanilla.tsx new file mode 100644 index 00000000..9bbd76e5 --- /dev/null +++ b/components/Skeleton/Skeleton.vanilla.tsx @@ -0,0 +1,55 @@ +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { RecipeVariants } from '@vanilla-extract/recipes'; +import { ElementType, forwardRef } from 'react'; + +import { CSSProps, processCSSProp } from '../../styles/cssProps'; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicRef, +} from '../../styles/polymorphic'; +import { useVanillaExtractTheme } from '../../styles/themeContext'; +import { skeletonRecipe } from './Skeleton.vanilla.css'; + +type SkeletonRecipeVariants = NonNullable>; + +interface SkeletonVanillaOwnProps extends SkeletonRecipeVariants, CSSProps {} + +export type SkeletonVanillaProps = PolymorphicComponentProps< + C, + SkeletonVanillaOwnProps +>; + +type SkeletonVanillaComponent = PolymorphicComponent<'div', SkeletonVanillaProps>; + +const SkeletonVanillaImpl = forwardRef( + ( + { as, className, css, style, variant, ...props }: SkeletonVanillaProps, + ref?: PolymorphicRef, + ) => { + const Component = as || 'div'; + const { colors } = useVanillaExtractTheme(); + const { style: cssStyles, vars } = processCSSProp(css, colors); + + const mergedStyles = { + ...cssStyles, + ...style, + ...assignInlineVars(vars), + }; + + const recipeClass = skeletonRecipe({ variant }); + + return ( + + ); + }, +); + +SkeletonVanillaImpl.displayName = 'SkeletonVanilla'; + +export const SkeletonVanilla = SkeletonVanillaImpl as SkeletonVanillaComponent; diff --git a/components/Skeleton/index.ts b/components/Skeleton/index.ts index 66bc08df..ef75745b 100644 --- a/components/Skeleton/index.ts +++ b/components/Skeleton/index.ts @@ -1 +1,3 @@ export * from './Skeleton'; +export type { SkeletonVanillaProps } from './Skeleton.vanilla'; +export { SkeletonVanilla } from './Skeleton.vanilla'; diff --git a/index.ts b/index.ts index d1573f51..236b93b6 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -export { AccessibleIcon } from './components/AccessibleIcon'; +export { AccessibleIcon, AccessibleIconVanilla } from './components/AccessibleIcon'; export { AccordionContent, AccordionItem, @@ -61,14 +61,15 @@ export { DropdownMenuSeparator, DropdownMenuTrigger, } from './components/DropdownMenu'; -export { Elevation, elevationVariants, ElevationVanilla } from './components/Elevation'; +export { Elevation, ElevationVanilla, elevationVariants } from './components/Elevation'; export { FaencyProvider } from './components/FaencyProvider'; export { Flex, FlexVanilla } from './components/Flex'; export { Grid, GridVanilla } from './components/Grid'; export { H1, H2, H3, H4, H5, H6 } from './components/Heading'; export type { IconButtonVanillaProps } from './components/IconButton'; export { IconButton, IconButtonVanilla } from './components/IconButton'; -export { Image } from './components/Image'; +export type { ImageVanillaProps } from './components/Image'; +export { Image, ImageVanilla } from './components/Image'; export type { InputHandle, InputVanillaHandle, InputVanillaProps } from './components/Input'; export { Input, InputVanilla } from './components/Input'; export type { LabelVanillaProps } from './components/Label'; @@ -97,6 +98,7 @@ export { NavigationTreeItem, } from './components/NavigationTree'; export { Overlay } from './components/Overlay'; +export { Panel, panelStyles, PanelVanilla } from './components/Panel'; export type { ParagraphVanillaProps } from './components/Paragraph'; export { Paragraph, ParagraphVanilla } from './components/Paragraph'; export { @@ -107,7 +109,6 @@ export { PopoverPortal, PopoverTrigger, } from './components/Popover'; -export { Panel, panelStyles, PanelVanilla } from './components/Panel'; export { Radio, RadioGroup } from './components/Radio'; export { RadioAccordionContent, @@ -117,7 +118,8 @@ export { } from './components/RadioAccordion'; export { Select } from './components/Select'; export { SidePanel } from './components/SidePanel'; -export { Skeleton } from './components/Skeleton'; +export type { SkeletonVanillaProps } from './components/Skeleton'; +export { Skeleton, SkeletonVanilla } from './components/Skeleton'; export { Switch } from './components/Switch'; export { Caption, Table, Tbody, Td, Tfoot, Th, Thead, Tr } from './components/Table'; export { TabsContainer, TabsContent, TabsList, TabsTrigger } from './components/Tabs'; diff --git a/package.json b/package.json index 4b75db6e..b4a03167 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,7 @@ ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" - }, - "./dist/style.css": "./dist/style.css" + } }, "files": [ "dist" diff --git a/styles/themes.css.ts b/styles/themes.css.ts index 7b3d5d0f..e3b8675e 100644 --- a/styles/themes.css.ts +++ b/styles/themes.css.ts @@ -16,6 +16,7 @@ import { } from '../components/IconButton/IconButton.theme.css'; import { inputDarkTheme, inputLightTheme } from '../components/Input/Input.theme.css'; import { panelDarkTheme, panelLightTheme } from '../components/Panel/Panel.theme.css'; +import { skeletonDarkTheme, skeletonLightTheme } from '../components/Skeleton/Skeleton.theme.css'; import { textDarkTheme, textLightTheme } from '../components/Text/Text.theme.css'; import { textareaDarkTheme, textareaLightTheme } from '../components/Textarea/Textarea.theme.css'; import { tokens } from './tokens.css'; @@ -260,6 +261,7 @@ const lightSemanticColors = { iconButtonHoverBorder: lightColors.slate9, iconButtonHoverBackground: lightColors.slateA3, iconButtonFocusBorder: lightColors.slate10, + ...skeletonLightTheme, }; export const lightThemeBlue = createTheme(tokens, { @@ -386,6 +388,7 @@ const darkSemanticColors = { iconButtonHoverBorder: darkColors.deepBlue6, iconButtonHoverBackground: darkColors.slateA4, iconButtonFocusBorder: darkColors.deepBlue7, + ...skeletonDarkTheme, }; export const darkThemeBlue = createTheme(tokens, { diff --git a/styles/tokens.css.ts b/styles/tokens.css.ts index 7ff50fa5..55853b55 100644 --- a/styles/tokens.css.ts +++ b/styles/tokens.css.ts @@ -161,6 +161,9 @@ export const tokens = createThemeContract({ buttonSwitchOffBg: null, buttonSwitchOffColor: null, buttonSwitchActiveColor: null, + // Component-specific tokens - Skeleton + skeletonBackground: null, + skeletonAnimation: null, }, fonts: { rubik: null,