From 1e8f37650ec1424af8883f2d2a4e09f361d0009c Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 11 Mar 2026 14:41:20 -0700 Subject: [PATCH] Add static Component.Part syntax --- CLAUDE.md | 2 + README.md | 37 ++++++++++++ docs/GUIDE.md | 73 +++++++++++++++++++++++ examples/landing.tsx | 37 ++++++++++++ src/index.tsx | 17 +++++- src/tests/index.test.tsx | 121 ++++++++++++++++++++++++++++++++++++++ src/tests/test_helpers.ts | 4 +- 7 files changed, 288 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 645017e..6860954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,9 @@ CSS is hostile to humans at scale: global namespace, no markup-to-definition lin **Parts** - Sub-components within a component (e.g., Header, Body, Footer) - Defined via `parts: { PartName: { ...styles } }` - Accessible in render as `parts.PartName` +- Also accessible as static properties: `Component.PartName` (no custom render needed) - Generate classes like `ComponentName_PartName` +- Variant part styles generate dual CSS: direct class (`.Part.variant`) and descendant (`.Root.variant .Part`) **Variants** - Conditional styling based on props - Boolean: `variants: { active: { color: 'blue' } }` → `` diff --git a/README.md b/README.md index e367455..b68524a 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,43 @@ console.log() console.log() ``` +### parts + `Component.Part` + +Parts are also available as static properties on the component, so you +can compose without writing a custom `render()`: + +```tsx +const Card = define("Card", { + padding: 20, + background: "#111", + + parts: { + Title: { base: "h2", fontSize: 24 }, + Body: { color: "gray" }, + }, + + variants: { + dark: { + background: "black", + parts: { + Body: { color: "white" }, + }, + }, + }, +}) + +// Usage + + Hello + World + + + + Dark mode + Variant styles reach parts automatically + +``` + ### selectors Use `selectors` to write custom CSS selectors. Reference the current diff --git a/docs/GUIDE.md b/docs/GUIDE.md index be6fc79..2244aea 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -416,6 +416,79 @@ Generated CSS classes: .Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; } ``` +### Static part access (`Component.Part`) + +Parts are also available as static properties on the component itself. This lets you compose components without writing a custom `render` function: + +```tsx +const Card = define('Card', { + padding: 20, + background: '#111', + + parts: { + Title: { base: 'h2', fontSize: 24 }, + Body: { padding: 10, color: '#888' }, + }, +}) + +// Use parts directly as Component.Part + + Hello + World + +``` + +This renders: + +```html +
+

Hello

+
World
+
+``` + +When a variant is set on the parent, descendant CSS selectors ensure the variant styles reach the static parts automatically: + +```tsx +const Tabs = define('Tabs', { + display: 'flex', + + parts: { + Tab: { base: 'button', color: 'gray' }, + }, + + variants: { + active: { + parts: { + Tab: { color: 'blue' }, + }, + }, + }, +}) + +// Variant on parent styles the child parts via descendant selectors + + Home + Settings + +``` + +Generated CSS (both selectors for both usage patterns): + +```css +/* For render({ parts }) path — direct class on part */ +.Tabs_Tab.active { color: blue; } +/* For Component.Part path — descendant of variant root */ +.Tabs.active .Tabs_Tab { color: blue; } +``` + +Static parts can also receive variant props directly for per-instance control: + +```tsx +Home +Settings +``` + ### Parts with states Parts can have their own pseudo-selectors: diff --git a/examples/landing.tsx b/examples/landing.tsx index 7f54513..779770e 100644 --- a/examples/landing.tsx +++ b/examples/landing.tsx @@ -426,6 +426,43 @@ console.log() console.log() \`\`\` +### parts + \`Component.Part\` + +Parts are also available as static properties on the component, so you +can compose without writing a custom \`render()\`: + +\`\`\`tsx +const Card = define("Card", { + padding: 20, + background: "#111", + + parts: { + Title: { base: "h2", fontSize: 24 }, + Body: { color: "gray" }, + }, + + variants: { + dark: { + background: "black", + parts: { + Body: { color: "white" }, + }, + }, + }, +}) + +// Usage + + Hello + World + + + + Dark mode + Variant styles reach parts automatically + +\`\`\` + ### selectors Use \`selectors\` to write custom CSS selectors. Reference the current diff --git a/src/index.tsx b/src/index.tsx index 820aeb5..ac06e5f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -245,8 +245,12 @@ function registerStyles(name: string, def: TagDef) { for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { const basePartClassName = makeClassName(name, partName) + // Direct class on part (for render({ parts }) path where parts inherit variant props) const partClassName = `${basePartClassName}.${variantName}` registerClassStyles(name, partClassName, partDef) + // Descendant selector (for Component.Part path where root has variant class) + const descendantClassName = `${baseClassName}.${variantName} .${basePartClassName}` + registerClassStyles(name, descendantClassName, partDef) } } else { // Keyed variant - iterate over the keys @@ -255,8 +259,13 @@ function registerStyles(name: string, def: TagDef) { registerClassStyles(name, className, variantDef) for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { + const basePartClassName = makeClassName(name, partName) + // Direct class on part (for render({ parts }) path) const partClassName = makeClassName(name, partName, variantName, variantKey) registerClassStyles(name, partClassName, partDef) + // Descendant selector (for Component.Part path) + const descendantClassName = `${className} .${basePartClassName}` + registerClassStyles(name, descendantClassName, partDef) } } } @@ -289,7 +298,7 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) { const currentProps: Record = {} const Root = makeComponent(name, def, currentProps) - return (props: Record) => { + const component = (props: Record) => { for (const key in currentProps) delete currentProps[key] Object.assign(currentProps, props) const parts: Record = { Root } @@ -299,6 +308,12 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) { return def.render?.({ props, parts }) ?? {props.children} } + + // Attach parts as static properties for Component.Part syntax + for (const [part] of Object.entries(def.parts ?? {})) + (component as any)[part] = makeComponent(name, def, {}, part) + + return component } // automatic names diff --git a/src/tests/index.test.tsx b/src/tests/index.test.tsx index bff980b..24a8358 100644 --- a/src/tests/index.test.tsx +++ b/src/tests/index.test.tsx @@ -1058,6 +1058,127 @@ describe('edge cases', () => { }) }) +describe('Component.Part static access', () => { + test('parts are accessible as static properties', () => { + const Component = define('StaticParts', { + parts: { + Header: { base: 'header' }, + Body: { base: 'main' } + } + }) + + expect(typeof (Component as any).Header).toBe('function') + expect(typeof (Component as any).Body).toBe('function') + }) + + test('static parts render with correct className and element', () => { + const Component = define('StaticPartRender', { + parts: { + Logo: { base: 'a', color: 'blue' }, + Nav: { base: 'nav', display: 'flex' } + } + }) as any + + const logoHtml = renderToString(Component.Logo({ href: '/', children: 'Home' })) + expect(logoHtml).toContain('class="StaticPartRender_Logo"') + expect(logoHtml).toContain(' { + const Card = define('StaticCard', { + padding: 20, + parts: { + Title: { base: 'h2', fontSize: 24 }, + Body: { base: 'div', padding: 10 } + } + }) as any + + const html = renderToString(Card({ + children: [ + Card.Title({ children: 'Hello' }), + Card.Body({ children: 'World' }) + ] + })) + expect(html).toContain('class="StaticCard"') + expect(html).toContain('class="StaticCard_Title"') + expect(html).toContain('class="StaticCard_Body"') + expect(html).toContain('Hello') + expect(html).toContain('World') + }) + + test('variant on parent applies to static parts via descendant selector CSS', () => { + define('StaticVariantParts', { + parts: { + Item: { color: 'black' } + }, + variants: { + theme: { + dark: { + backgroundColor: 'black', + parts: { + Item: { color: 'white' } + } + } + } + } + }) + + const css = getStylesCSS() + // Descendant selector for Component.Part usage + expect(css).toContain('.StaticVariantParts.theme-dark .StaticVariantParts_Item') + // Direct class selector still generated for render({ parts }) usage + expect(css).toContain('.StaticVariantParts_Item.theme-dark') + }) + + test('boolean variant on parent generates descendant selector CSS', () => { + define('StaticBoolVariant', { + parts: { + Icon: { base: 'span', opacity: 0.5 } + }, + variants: { + active: { + parts: { + Icon: { opacity: 1 } + } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.StaticBoolVariant.active .StaticBoolVariant_Icon') + expect(css).toContain('.StaticBoolVariant_Icon.active') + }) + + test('static parts can receive variant props directly', () => { + const Tabs = define('StaticTabs', { + parts: { + Tab: { base: 'button', color: 'gray' } + }, + variants: { + active: { + parts: { + Tab: { color: 'blue' } + } + } + } + }) as any + + const activeHtml = renderToString(Tabs.Tab({ active: true, children: 'Home' })) + expect(activeHtml).toContain('StaticTabs_Tab active') + expect(activeHtml).toContain('Home') + + const inactiveHtml = renderToString(Tabs.Tab({ children: 'Settings' })) + expect(inactiveHtml).toContain('class="StaticTabs_Tab"') + expect(inactiveHtml).not.toContain('active') + }) +}) + describe('css() - global styles', () => { test('registers bare element selectors', () => { css({ diff --git a/src/tests/test_helpers.ts b/src/tests/test_helpers.ts index d615394..3b26fca 100644 --- a/src/tests/test_helpers.ts +++ b/src/tests/test_helpers.ts @@ -19,12 +19,12 @@ export function parseCSS(css: string): Record> { const className = match[1] const cssText = match[2] - styles[className] = {} + styles[className] ??= {} const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g)) for (const propMatch of propMatches) { if (!propMatch[1] || !propMatch[2]) continue - styles[className]![propMatch[1].trim()] = propMatch[2].trim() + styles[className]![propMatch[1].trim()] ??= propMatch[2].trim() } }