Compare commits
No commits in common. "8571b544e6f7744524c0e38ed96849bf5b67a9d5" and "22f1abc625d0d16fe1f9aa8a74cc0acfd4c8986e" have entirely different histories.
8571b544e6
...
22f1abc625
|
|
@ -24,9 +24,7 @@ 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)
|
**Parts** - Sub-components within a component (e.g., Header, Body, Footer)
|
||||||
- Defined via `parts: { PartName: { ...styles } }`
|
- Defined via `parts: { PartName: { ...styles } }`
|
||||||
- Accessible in render as `parts.PartName`
|
- Accessible in render as `parts.PartName`
|
||||||
- Also accessible as static properties: `Component.PartName` (no custom render needed)
|
|
||||||
- Generate classes like `ComponentName_PartName`
|
- 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
|
**Variants** - Conditional styling based on props
|
||||||
- Boolean: `variants: { active: { color: 'blue' } }` → `<Component active />`
|
- Boolean: `variants: { active: { color: 'blue' } }` → `<Component active />`
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -118,43 +118,6 @@ console.log(<Profile pic={user.pic} bio={user.bio} />)
|
||||||
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
|
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
|
||||||
<Card>
|
|
||||||
<Card.Title>Hello</Card.Title>
|
|
||||||
<Card.Body>World</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card dark>
|
|
||||||
<Card.Title>Dark mode</Card.Title>
|
|
||||||
<Card.Body>Variant styles reach parts automatically</Card.Body>
|
|
||||||
</Card>
|
|
||||||
```
|
|
||||||
|
|
||||||
### selectors
|
### selectors
|
||||||
|
|
||||||
Use `selectors` to write custom CSS selectors. Reference the current
|
Use `selectors` to write custom CSS selectors. Reference the current
|
||||||
|
|
|
||||||
|
|
@ -416,79 +416,6 @@ Generated CSS classes:
|
||||||
.Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; }
|
.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
|
|
||||||
<Card>
|
|
||||||
<Card.Title>Hello</Card.Title>
|
|
||||||
<Card.Body>World</Card.Body>
|
|
||||||
</Card>
|
|
||||||
```
|
|
||||||
|
|
||||||
This renders:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="Card">
|
|
||||||
<h2 class="Card_Title">Hello</h2>
|
|
||||||
<div class="Card_Body">World</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
<Tabs active>
|
|
||||||
<Tabs.Tab>Home</Tabs.Tab>
|
|
||||||
<Tabs.Tab>Settings</Tabs.Tab>
|
|
||||||
</Tabs>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
<Tabs.Tab active>Home</Tabs.Tab> <!-- class="Tabs_Tab active" -->
|
|
||||||
<Tabs.Tab>Settings</Tabs.Tab> <!-- class="Tabs_Tab" -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parts with states
|
### Parts with states
|
||||||
|
|
||||||
Parts can have their own pseudo-selectors:
|
Parts can have their own pseudo-selectors:
|
||||||
|
|
|
||||||
|
|
@ -426,43 +426,6 @@ console.log(<Profile pic={user.pic} bio={user.bio} />)
|
||||||
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
|
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### 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
|
|
||||||
<Card>
|
|
||||||
<Card.Title>Hello</Card.Title>
|
|
||||||
<Card.Body>World</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card dark>
|
|
||||||
<Card.Title>Dark mode</Card.Title>
|
|
||||||
<Card.Body>Variant styles reach parts automatically</Card.Body>
|
|
||||||
</Card>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### selectors
|
### selectors
|
||||||
|
|
||||||
Use \`selectors\` to write custom CSS selectors. Reference the current
|
Use \`selectors\` to write custom CSS selectors. Reference the current
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/forge",
|
"name": "@because/forge",
|
||||||
"version": "0.0.5",
|
"version": "0.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.tsx",
|
"main": "src/index.tsx",
|
||||||
"module": "src/index.tsx",
|
"module": "src/index.tsx",
|
||||||
|
|
|
||||||
|
|
@ -245,12 +245,8 @@ function registerStyles(name: string, def: TagDef) {
|
||||||
|
|
||||||
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
||||||
const basePartClassName = makeClassName(name, partName)
|
const basePartClassName = makeClassName(name, partName)
|
||||||
// Direct class on part (for render({ parts }) path where parts inherit variant props)
|
|
||||||
const partClassName = `${basePartClassName}.${variantName}`
|
const partClassName = `${basePartClassName}.${variantName}`
|
||||||
registerClassStyles(name, partClassName, partDef)
|
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 {
|
} else {
|
||||||
// Keyed variant - iterate over the keys
|
// Keyed variant - iterate over the keys
|
||||||
|
|
@ -259,13 +255,8 @@ function registerStyles(name: string, def: TagDef) {
|
||||||
registerClassStyles(name, className, variantDef)
|
registerClassStyles(name, className, variantDef)
|
||||||
|
|
||||||
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
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)
|
const partClassName = makeClassName(name, partName, variantName, variantKey)
|
||||||
registerClassStyles(name, partClassName, partDef)
|
registerClassStyles(name, partClassName, partDef)
|
||||||
// Descendant selector (for Component.Part path)
|
|
||||||
const descendantClassName = `${className} .${basePartClassName}`
|
|
||||||
registerClassStyles(name, descendantClassName, partDef)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,23 +288,17 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
|
||||||
|
|
||||||
const currentProps: Record<string, any> = {}
|
const currentProps: Record<string, any> = {}
|
||||||
const Root = makeComponent(name, def, currentProps)
|
const Root = makeComponent(name, def, currentProps)
|
||||||
const parts: Record<string, Function> = { Root }
|
|
||||||
|
|
||||||
for (const [part] of Object.entries(def.parts ?? {}))
|
return (props: Record<string, any>) => {
|
||||||
parts[part] = makeComponent(name, def, currentProps, part)
|
|
||||||
|
|
||||||
const component = (props: Record<string, any>) => {
|
|
||||||
for (const key in currentProps) delete currentProps[key]
|
for (const key in currentProps) delete currentProps[key]
|
||||||
Object.assign(currentProps, props)
|
Object.assign(currentProps, props)
|
||||||
|
const parts: Record<string, Function> = { Root }
|
||||||
|
|
||||||
|
for (const [part] of Object.entries(def.parts ?? {}))
|
||||||
|
parts[part] = makeComponent(name, def, props, part)
|
||||||
|
|
||||||
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
|
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// automatic names
|
||||||
|
|
|
||||||
|
|
@ -1058,127 +1058,6 @@ 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('<a')
|
|
||||||
expect(logoHtml).toContain('href="/"')
|
|
||||||
expect(logoHtml).toContain('Home')
|
|
||||||
|
|
||||||
const navHtml = renderToString(Component.Nav({ children: 'Links' }))
|
|
||||||
expect(navHtml).toContain('class="StaticPartRender_Nav"')
|
|
||||||
expect(navHtml).toContain('<nav')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('static parts work as JSX children of parent component', () => {
|
|
||||||
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', () => {
|
describe('css() - global styles', () => {
|
||||||
test('registers bare element selectors', () => {
|
test('registers bare element selectors', () => {
|
||||||
css({
|
css({
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ export function parseCSS(css: string): Record<string, Record<string, string>> {
|
||||||
|
|
||||||
const className = match[1]
|
const className = match[1]
|
||||||
const cssText = match[2]
|
const cssText = match[2]
|
||||||
styles[className] ??= {}
|
styles[className] = {}
|
||||||
|
|
||||||
const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g))
|
const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g))
|
||||||
for (const propMatch of propMatches) {
|
for (const propMatch of propMatches) {
|
||||||
if (!propMatch[1] || !propMatch[2]) continue
|
if (!propMatch[1] || !propMatch[2]) continue
|
||||||
styles[className]![propMatch[1].trim()] ??= propMatch[2].trim()
|
styles[className]![propMatch[1].trim()] = propMatch[2].trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user