This commit is contained in:
Chris Wanstrath 2025-12-26 21:41:36 -08:00
parent 097dae4f2a
commit 966a0ce4b0
8 changed files with 571 additions and 530 deletions

View File

@ -6,16 +6,15 @@ Example:
import { define } from "forge"
export const Button = define("button", {
layout: {
base: "button",
padding: 20,
},
look: {
background: "blue",
},
variants: {
kind: {
danger: { look: { background: "red" } },
warning: { look: { background: "yellow" } },
danger: { background: "red" },
warning: { background: "yellow" },
}
},
})
@ -26,35 +25,31 @@ export const Button = define("button", {
<Button kind="warning">Click me?</Button>
export const Profile = define("div", {
layout: {
padding: 50,
},
look: {
background: "red",
},
parts: {
header: { layout: { display: "flex" } },
avatar: { layout: { width: 50 } },
bio: { look: { color: "gray" } },
Header: { display: "flex" },
Avatar: { base: 'img', width: 50 },
Bio: { color: "gray" },
},
variants: {
size: {
small: {
parts: { avatar: { layout: { width: 20 }}}
parts: { Avatar: { width: 20 }}
}
}
},
render({ props, parts: { root, header, avatar, bio } }) {
render({ props, parts: { Root, Header, Avatar, Bio } }) {
return (
<root>
<header>
<avatar src={props.pic} />
<bio>{props.bio}</bio>
</header>
</root>
<Root>
<Header>
<Avatar src={props.pic} />
<Bio>{props.bio}</Bio>
</Header>
</Root>
)
},
})

View File

@ -4,15 +4,11 @@ import { Layout, ExampleSection } from './helpers'
const Button = define('Button', {
base: 'button',
layout: {
padding: "12px 24px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
},
look: {
background: "#3b82f6",
color: "white",
border: "none",
@ -24,89 +20,64 @@ const Button = define('Button', {
userSelect: "none",
boxShadow: "0 4px 6px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
transform: "translateY(0)",
},
states: {
":not(:disabled):hover": {
look: {
transform: 'translateY(-2px) !important',
filter: 'brightness(1.05)'
}
},
":not(:disabled):active": {
look: {
transform: 'translateY(1px) !important',
boxShadow: '0 2px 3px rgba(0, 0, 0, 0.2) !important'
}
},
},
variants: {
intent: {
primary: {
look: {
background: "#3b82f6",
color: "white",
boxShadow: "0 4px 6px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
},
},
secondary: {
look: {
background: "#f3f4f6",
color: "#374151",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)",
},
},
danger: {
look: {
background: "#ef4444",
color: "white",
boxShadow: "0 4px 6px rgba(239, 68, 68, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
},
},
ghost: {
look: {
background: "transparent",
color: "#aaa",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1)",
border: "1px solid #eee",
},
},
},
size: {
small: {
layout: {
padding: "8px 16px",
},
look: {
fontSize: 14,
},
},
large: {
layout: {
padding: "16px 32px",
},
look: {
fontSize: 18,
},
},
},
disabled: {
look: {
opacity: 0.5,
cursor: "not-allowed",
},
},
},
})
const ButtonRow = define('ButtonRow', {
layout: {
display: 'flex',
gap: 16,
flexWrap: 'wrap',
alignItems: 'center',
}
})
export const ButtonExamplesPage = () => (

View File

@ -2,49 +2,36 @@ import { define, Styles } from '../src'
export const Body = define('Body', {
base: 'body',
layout: {
margin: 0,
padding: '40px 20px',
},
look: {
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: '#f3f4f6',
}
})
const Container = define('Container', {
layout: {
maxWidth: 1200,
margin: '0 auto'
}
})
export const Header = define('Header', {
base: 'h1',
layout: {
marginBottom: 40,
},
look: {
color: '#111827'
}
})
export const ExampleSection = define('ExampleSection', {
layout: {
marginBottom: 40
},
marginBottom: 40,
parts: {
Header: {
base: 'h2',
layout: {
marginBottom: 16
},
look: {
marginBottom: 16,
color: '#374151',
fontSize: 18
}
}
},
render({ props, parts: { Root, Header } }) {
return (

View File

@ -2,23 +2,17 @@ import { define } from '../src'
import { Layout, ExampleSection } from './helpers'
const Tabs = define('Tabs', {
layout: {
display: 'flex',
gap: 0,
},
look: {
borderBottom: '2px solid #e5e7eb',
},
parts: {
Tab: {
base: 'button',
layout: {
padding: '12px 24px',
position: 'relative',
marginBottom: -2,
},
look: {
background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent',
@ -27,25 +21,21 @@ const Tabs = define('Tabs', {
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
},
states: {
':hover': {
look: {
color: '#111827',
}
}
}
}
},
variants: {
active: {
parts: {
Tab: {
look: {
color: '#3b82f6',
borderBottom: '2px solid #3b82f6',
},
}
}
}
@ -69,19 +59,15 @@ const Tabs = define('Tabs', {
})
const Pills = define('Pills', {
layout: {
display: 'flex',
gap: 8,
flexWrap: 'wrap',
},
parts: {
Pill: {
base: 'button',
layout: {
padding: '8px 16px',
},
look: {
background: '#f3f4f6',
border: 'none',
borderRadius: 20,
@ -90,29 +76,24 @@ const Pills = define('Pills', {
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
},
states: {
':hover': {
look: {
background: '#e5e7eb',
color: '#111827',
}
}
}
}
},
variants: {
active: {
parts: {
Pill: {
look: {
background: '#3b82f6',
color: 'white',
},
states: {
':hover': {
look: {
background: '#2563eb',
color: 'white',
}
@ -120,7 +101,6 @@ const Pills = define('Pills', {
}
}
}
}
},
render({ props, parts: { Root, Pill } }) {
@ -141,23 +121,20 @@ const Pills = define('Pills', {
})
const VerticalNav = define('VerticalNav', {
layout: {
display: 'flex',
flexDirection: 'column',
gap: 4,
width: 240,
},
parts: {
NavItem: {
base: 'a',
layout: {
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 12,
},
look: {
background: 'transparent',
borderRadius: 8,
color: '#6b7280',
@ -166,41 +143,32 @@ const VerticalNav = define('VerticalNav', {
textDecoration: 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
},
states: {
':hover': {
look: {
background: '#f3f4f6',
color: '#111827',
}
}
}
},
Icon: {
layout: {
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
look: {
fontSize: 18,
}
}
},
variants: {
active: {
parts: {
NavItem: {
look: {
background: '#eff6ff',
color: '#3b82f6',
},
states: {
':hover': {
look: {
background: '#dbeafe',
color: '#2563eb',
}
@ -208,7 +176,6 @@ const VerticalNav = define('VerticalNav', {
}
}
}
}
},
render({ props, parts: { Root, NavItem, Icon } }) {
@ -230,44 +197,36 @@ const VerticalNav = define('VerticalNav', {
})
const Breadcrumbs = define('Breadcrumbs', {
layout: {
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
parts: {
Item: {
base: 'a',
look: {
color: '#6b7280',
fontSize: 14,
textDecoration: 'none',
transition: 'color 0.2s ease',
},
states: {
':hover': {
look: {
color: '#3b82f6',
}
}
}
},
Separator: {
look: {
color: '#d1d5db',
fontSize: 14,
userSelect: 'none',
}
},
Current: {
look: {
color: '#111827',
fontSize: 14,
fontWeight: 500,
}
}
},
render({ props, parts: { Root, Item, Separator, Current } }) {

View File

@ -4,167 +4,129 @@ import { Layout, ExampleSection } from './helpers'
const UserProfile = define('UserProfile', {
base: 'div',
layout: {
padding: 24,
maxWidth: 600,
margin: "0 auto",
},
look: {
background: "white",
borderRadius: 12,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
},
parts: {
Header: {
layout: {
display: "flex",
alignItems: "center",
gap: 16,
marginBottom: 16,
},
},
Avatar: {
base: 'img',
layout: {
width: 64,
height: 64,
},
look: {
borderRadius: "50%",
objectFit: "cover",
border: "3px solid #e5e7eb",
},
},
Info: {
layout: {
flex: 1,
},
},
Name: {
layout: {
marginBottom: 4,
},
look: {
fontSize: 20,
fontWeight: 600,
color: "#111827",
},
},
Handle: {
look: {
fontSize: 14,
color: "#6b7280",
},
},
Bio: {
layout: {
marginBottom: 16,
width: "100%",
},
look: {
fontSize: 14,
lineHeight: 1.6,
color: "#374151",
wordWrap: "break-word",
},
},
Stats: {
layout: {
display: "flex",
gap: 24,
paddingTop: 16,
},
look: {
borderTop: "1px solid #e5e7eb",
},
},
Stat: {
layout: {
display: "flex",
flexDirection: "column",
gap: 4,
},
},
StatValue: {
look: {
fontSize: 18,
fontWeight: 600,
color: "#111827",
},
},
StatLabel: {
look: {
fontSize: 12,
color: "#6b7280",
textTransform: "uppercase",
},
},
},
variants: {
size: {
compact: {
layout: { padding: 16, maxWidth: 300 },
padding: 16,
maxWidth: 300,
parts: {
Avatar: { layout: { width: 48, height: 48 } },
Name: { look: { fontSize: 16 } },
Bio: { look: { fontSize: 13 } },
Avatar: { width: 48, height: 48 },
Name: { fontSize: 16 },
Bio: { fontSize: 13 },
},
},
skinny: {
layout: { padding: 20, maxWidth: 125 },
padding: 20,
maxWidth: 125,
parts: {
Header: {
layout: {
flexDirection: "column",
alignItems: "center",
gap: 12,
}
},
Avatar: { layout: { width: 80, height: 80 } },
Info: { layout: { flex: 0, width: "100%" } },
Name: { look: { textAlign: "center", fontSize: 18 } },
Handle: { look: { textAlign: "center" } },
Bio: { look: { textAlign: "center", fontSize: 13 } },
Avatar: { width: 80, height: 80 },
Info: { flex: 0, width: "100%" },
Name: { textAlign: "center", fontSize: 18 },
Handle: { textAlign: "center" },
Bio: { textAlign: "center", fontSize: 13 },
Stats: {
layout: {
flexDirection: "column",
gap: 16,
}
},
Stat: { layout: { alignItems: "center" } },
Stat: { alignItems: "center" },
},
},
large: {
layout: { padding: 32, maxWidth: 800 },
padding: 32,
maxWidth: 800,
parts: {
Avatar: { layout: { width: 96, height: 96 } },
Name: { look: { fontSize: 24 } },
Avatar: { width: 96, height: 96 },
Name: { fontSize: 24 },
},
},
},
verified: {
parts: {
Avatar: {
look: {
border: "3px solid #3b82f6",
},
},
},
},
theme: {
dark: {
look: {
background: "#1f2937",
},
parts: {
Name: { look: { color: "#f9fafb" } },
Handle: { look: { color: "#9ca3af" } },
Bio: { look: { color: "#d1d5db" } },
Stats: { look: { borderTop: "1px solid #374151" } },
StatValue: { look: { color: "#f9fafb" } },
Name: { color: "#f9fafb" },
Handle: { color: "#9ca3af" },
Bio: { color: "#d1d5db" },
Stats: { borderTop: "1px solid #374151" },
StatValue: { color: "#f9fafb" },
},
},
},

View File

@ -1,5 +1,5 @@
import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps } from './types'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
export const styles: Record<string, Record<string, string>> = {}
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} />
@ -45,8 +45,10 @@ function camelToDash(name: string): string {
function makeStyle(def: TagDef) {
const style: Record<string, string> = {}
for (const [name, value] of Object.entries(Object.assign({}, def.layout ?? {}, def.look ?? {})))
for (const [name, value] of Object.entries(def)) {
if (NonStyleKeys.has(name)) continue
style[camelToDash(name)] = `${typeof value === 'number' && !UnitlessProps.has(name) ? `${value}px` : value}`
}
return style
}
@ -57,28 +59,37 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
const base = def.base ?? 'div'
const Tag = (base) as keyof JSX.IntrinsicElements
return ({ children, ...props }: { children: any, [key: string]: any }) => {
const classNames = [makeClassName(baseName, partName)]
for (const [key, value] of Object.entries(rootProps)) {
const allProps = { ...rootProps, ...props }
for (const [key, value] of Object.entries(allProps)) {
const variantConfig = rootDef.variants?.[key]
if (!variantConfig) continue
// Remove variant prop from being passed to HTML element
delete props[key]
const variantName = key
const variantKey = value
let variantDef: TagDef | undefined
if ('parts' in variantConfig || 'layout' in variantConfig || 'look' in variantConfig) {
if (value === true) variantDef = variantConfig as TagDef
} else {
variantDef = (variantConfig as Record<string, TagDef>)[value as string]
// Distinguish boolean variants from keyed variants:
// - Boolean variants: component({ variant: true }) → variantConfig is a TagDef
// - Keyed variants: component({ variant: 'key' }) → variantConfig[key] is a TagDef
if (value === true) {
variantDef = variantConfig as TagDef
} else if (typeof value === 'string') {
variantDef = (variantConfig as Record<string, TagDef>)[value]
}
if (!variantDef) continue
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
}
return ({ children, ...props }: { children: any, [key: string]: any }) =>
<Tag class={classNames.join(' ')} {...props}>{children}</Tag>
return <Tag class={classNames.join(' ')} {...props}>{children}</Tag>
}
}
// adds CSS styles for tag definition
@ -98,13 +109,17 @@ function registerStyles(name: string, def: TagDef) {
}
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
// Check if it's a boolean variant (has layout/look/parts directly) or a keyed variant
if ('parts' in variantConfig || 'layout' in variantConfig || 'look' in variantConfig) {
// Boolean variant - treat variantConfig as TagDef
// Detect boolean vs keyed variants by checking if config has structural keys or looks like a TagDef
const isBooleanVariant = 'parts' in variantConfig || 'styles' in variantConfig || 'states' in variantConfig ||
// If first key is camelCase or contains CSS-like properties, treat as boolean variant
Object.keys(variantConfig).some(k => k !== k.toLowerCase() || typeof (variantConfig as any)[k] !== 'object')
if (isBooleanVariant) {
// Boolean variant - variantConfig is a TagDef
const variantDef = variantConfig as TagDef
const baseClassName = makeClassName(name)
const className = `${baseClassName}.${variantName}`
styles[className] ??= makeStyle({ layout: variantDef.layout, look: variantDef.look })
styles[className] ??= makeStyle(variantDef)
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${state}`] = makeStyle(style)
@ -119,7 +134,7 @@ function registerStyles(name: string, def: TagDef) {
// Keyed variant - iterate over the keys
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
const className = makeClassName(name, undefined, variantName, variantKey)
styles[className] ??= makeStyle({ layout: variantDef.layout, look: variantDef.look })
styles[className] ??= makeStyle(variantDef)
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${state}`] = makeStyle(style)

View File

@ -5,7 +5,7 @@ import { renderToString, getStylesCSS, parseCSS } from './test_helpers'
describe('define - basic functionality', () => {
test('creates a component function', () => {
const Component = define({
layout: { display: 'flex' }
display: 'flex'
})
expect(typeof Component).toBe('function')
@ -13,7 +13,7 @@ describe('define - basic functionality', () => {
test('component returns a JSX element', () => {
const Component = define({
layout: { display: 'flex' }
display: 'flex'
})
const result = Component({})
@ -23,7 +23,7 @@ describe('define - basic functionality', () => {
test('applies className to rendered element', () => {
const Component = define('MyComponent', {
layout: { display: 'flex' }
display: 'flex'
})
const html = renderToString(Component({}))
@ -31,8 +31,8 @@ describe('define - basic functionality', () => {
})
test('generates unique anonymous component names', () => {
const Component1 = define({ layout: { display: 'flex' } })
const Component2 = define({ layout: { display: 'block' } })
const Component1 = define({ display: 'flex' })
const Component2 = define({ display: 'block' })
const html1 = renderToString(Component1({}))
const html2 = renderToString(Component2({}))
@ -45,7 +45,7 @@ describe('define - basic functionality', () => {
test('renders default div element', () => {
const Component = define('DivTest', {
layout: { display: 'flex' }
display: 'flex'
})
const html = renderToString(Component({}))
@ -56,7 +56,7 @@ describe('define - basic functionality', () => {
test('respects custom base element', () => {
const Component = define('ButtonTest', {
base: 'button',
look: { color: 'blue' }
color: 'blue'
})
const html = renderToString(Component({}))
@ -83,11 +83,9 @@ describe('define - basic functionality', () => {
describe('CSS generation - camelCase to kebab-case', () => {
test('converts camelCase properties to kebab-case', () => {
define('CamelTest', {
layout: {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between'
}
})
const css = getStylesCSS()
@ -98,10 +96,8 @@ describe('CSS generation - camelCase to kebab-case', () => {
test('handles consecutive capital letters', () => {
define('ConsecutiveTest', {
look: {
backgroundColor: 'red',
borderRadius: 5
}
})
const css = getStylesCSS()
@ -113,12 +109,10 @@ describe('CSS generation - camelCase to kebab-case', () => {
describe('CSS generation - numeric values and units', () => {
test('adds px unit to numeric layout values', () => {
define('NumericTest', {
layout: {
width: 100,
height: 200,
padding: 16,
margin: 8
}
})
const styles = parseCSS(getStylesCSS())
@ -130,12 +124,10 @@ describe('CSS generation - numeric values and units', () => {
test('preserves string values without adding px', () => {
define('StringTest', {
layout: {
width: '100%',
height: 'auto',
margin: '0 auto',
padding: '1rem'
}
})
const styles = parseCSS(getStylesCSS())
@ -147,18 +139,14 @@ describe('CSS generation - numeric values and units', () => {
test('does not add px to unitless properties', () => {
define('UnitlessTest', {
layout: {
flex: 1,
flexGrow: 2,
flexShrink: 1,
zIndex: 10,
order: 3
},
look: {
order: 3,
opacity: 0.5,
fontWeight: 700,
lineHeight: 1.5
}
})
const styles = parseCSS(getStylesCSS())
@ -174,14 +162,10 @@ describe('CSS generation - numeric values and units', () => {
test('handles numeric zero values correctly', () => {
define('ZeroTest', {
layout: {
margin: 0,
padding: 0,
zIndex: 0
},
look: {
zIndex: 0,
opacity: 0
}
})
const styles = parseCSS(getStylesCSS())
@ -195,12 +179,10 @@ describe('CSS generation - numeric values and units', () => {
describe('CSS generation - layout and look', () => {
test('generates CSS for layout properties', () => {
define('LayoutTest', {
layout: {
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 20
}
})
const css = getStylesCSS()
@ -213,12 +195,10 @@ describe('CSS generation - layout and look', () => {
test('generates CSS for look properties', () => {
define('LookTest', {
look: {
color: 'blue',
backgroundColor: 'white',
fontSize: 16,
fontWeight: 600
}
})
const css = getStylesCSS()
@ -231,14 +211,10 @@ describe('CSS generation - layout and look', () => {
test('combines layout and look properties', () => {
define('CombinedTest', {
layout: {
display: 'flex',
padding: 16
},
look: {
padding: 16,
color: 'blue',
backgroundColor: 'white'
}
})
const styles = parseCSS(getStylesCSS())
@ -253,11 +229,11 @@ describe('CSS generation - layout and look', () => {
describe('CSS generation - parts', () => {
test('generates separate CSS for each part', () => {
define('PartTest', {
layout: { display: 'flex' },
display: 'flex',
parts: {
Header: { base: 'header', look: { color: 'red', fontSize: 20 } },
Body: { base: 'main', layout: { padding: 20 } },
Footer: { base: 'footer', look: { fontSize: 12 } }
Header: { base: 'header', color: 'red', fontSize: 20 },
Body: { base: 'main', padding: 20 },
Footer: { base: 'footer', fontSize: 12 }
}
})
@ -271,7 +247,7 @@ describe('CSS generation - parts', () => {
test('part className format is ComponentName_PartName', () => {
define('ComponentWithParts', {
parts: {
MyPart: { look: { color: 'green' } }
MyPart: { color: 'green' }
}
})
@ -345,10 +321,10 @@ describe('components with parts', () => {
describe('variants - boolean variants', () => {
test('applies boolean variant class when true', () => {
const Component = define('BoolVariant', {
layout: { display: 'flex' },
display: 'flex',
variants: {
primary: {
look: { color: 'blue' }
color: 'blue'
}
}
})
@ -362,7 +338,7 @@ describe('variants - boolean variants', () => {
const Component = define('BoolVariantFalse', {
variants: {
active: {
look: { backgroundColor: 'green' }
backgroundColor: 'green'
}
}
})
@ -379,10 +355,10 @@ describe('variants - boolean variants', () => {
test('generates CSS for component with boolean variant', () => {
define('BoolVariantCSS', {
layout: { display: 'block' },
display: 'block',
variants: {
active: {
look: { backgroundColor: 'green' }
backgroundColor: 'green'
}
}
})
@ -398,9 +374,9 @@ describe('variants - string/enum variants', () => {
const Component = define('StringVariant', {
variants: {
size: {
small: { layout: { padding: 8 } },
medium: { layout: { padding: 16 } },
large: { layout: { padding: 24 } }
small: { padding: 8 },
medium: { padding: 16 },
large: { padding: 24 }
}
}
})
@ -416,9 +392,9 @@ describe('variants - string/enum variants', () => {
define('ColorVariant', {
variants: {
color: {
red: { look: { color: 'red', backgroundColor: '#ffeeee' } },
blue: { look: { color: 'blue', backgroundColor: '#eeeeff' } },
green: { look: { color: 'green', backgroundColor: '#eeffee' } }
red: { color: 'red', backgroundColor: '#ffeeee' },
blue: { color: 'blue', backgroundColor: '#eeeeff' },
green: { color: 'green', backgroundColor: '#eeffee' }
}
}
})
@ -433,12 +409,12 @@ describe('variants - string/enum variants', () => {
const Component = define('MultiVariant', {
variants: {
size: {
small: { layout: { padding: 8 } },
large: { layout: { padding: 24 } }
small: { padding: 8 },
large: { padding: 24 }
},
color: {
red: { look: { color: 'red' } },
blue: { look: { color: 'blue' } }
red: { color: 'red' },
blue: { color: 'blue' }
}
}
})
@ -452,7 +428,7 @@ describe('variants - string/enum variants', () => {
const Component = define('UndefinedVariant', {
variants: {
size: {
small: { layout: { padding: 8 } }
small: { padding: 8 }
}
}
})
@ -466,7 +442,7 @@ describe('variants - string/enum variants', () => {
const Component = define('NonVariantProps', {
variants: {
size: {
small: { layout: { padding: 8 } }
small: { padding: 8 }
}
}
})
@ -488,12 +464,12 @@ describe('variants with parts', () => {
theme: {
dark: {
parts: {
Header: { look: { color: 'white', backgroundColor: 'black' } }
Header: { color: 'white', backgroundColor: 'black' }
}
},
light: {
parts: {
Header: { look: { color: 'black', backgroundColor: 'white' } }
Header: { color: 'black', backgroundColor: 'white' }
}
}
}
@ -514,7 +490,7 @@ describe('variants with parts', () => {
},
variants: {
size: {
large: { layout: { padding: 20 } }
large: { padding: 20 }
}
},
render: ({ props, parts }) => {
@ -531,7 +507,7 @@ describe('variants with parts', () => {
describe('custom render function', () => {
test('uses custom render when provided', () => {
const Component = define('CustomRender', {
layout: { display: 'flex' },
display: 'flex',
render: ({ props, parts }) => {
return <div class="custom-wrapper">{props.children}</div>
}
@ -596,7 +572,7 @@ describe('Styles component', () => {
})
test('Styles renders to HTML with CSS', () => {
define('StylesComp1', { layout: { width: 100 } })
define('StylesComp1', { width: 100 })
const html = renderToString(define.Styles())
expect(html).toContain('<style>')
@ -606,8 +582,8 @@ describe('Styles component', () => {
})
test('Styles includes CSS for all registered components', () => {
define('StylesComp2', { layout: { width: 100 } })
define('StylesComp3', { layout: { height: 200 } })
define('StylesComp2', { width: 100 })
define('StylesComp3', { height: 200 })
const css = getStylesCSS()
expect(css).toContain('.StylesComp2')
@ -620,8 +596,8 @@ describe('Styles component', () => {
define('StylesVariant', {
variants: {
size: {
small: { layout: { padding: 8 } },
large: { layout: { padding: 24 } }
small: { padding: 8 },
large: { padding: 24 }
}
}
})
@ -636,8 +612,8 @@ describe('Styles component', () => {
test('Styles includes part CSS', () => {
define('StylesPart', {
parts: {
Header: { look: { color: 'red' } },
Body: { look: { color: 'blue' } }
Header: { color: 'red' },
Body: { color: 'blue' }
}
})
@ -649,6 +625,125 @@ describe('Styles component', () => {
})
})
describe('variants on parts via props', () => {
test('applies boolean variant to part when passed as prop', () => {
const Component = define('PartVariantBool', {
parts: {
Tab: { base: 'button', color: 'gray' }
},
variants: {
active: {
parts: {
Tab: { color: 'blue' }
}
}
},
render: ({ props, parts }) => {
return (
<parts.Root>
<parts.Tab active={true}>Active Tab</parts.Tab>
<parts.Tab active={false}>Inactive Tab</parts.Tab>
</parts.Root>
)
}
})
const html = renderToString(Component({}))
// Should have one tab with active class, one without
expect(html).toContain('PartVariantBool_Tab active')
// Count occurrences - should have 2 tabs total
const tabCount = (html.match(/PartVariantBool_Tab/g) || []).length
expect(tabCount).toBe(2)
})
test('applies string variant to part when passed as prop', () => {
const Component = define('PartVariantString', {
parts: {
Pill: { base: 'button' }
},
variants: {
size: {
small: { parts: { Pill: { padding: 8 } } },
large: { parts: { Pill: { padding: 24 } } }
}
},
render: ({ props, parts }) => {
return (
<parts.Root>
<parts.Pill size="small">Small</parts.Pill>
<parts.Pill size="large">Large</parts.Pill>
</parts.Root>
)
}
})
const html = renderToString(Component({}))
expect(html).toContain('PartVariantString_Pill size-small')
expect(html).toContain('PartVariantString_Pill size-large')
})
test('does not pass variant props through to HTML', () => {
const Component = define('NoVariantLeakage', {
parts: {
Item: { base: 'div' }
},
variants: {
active: {
parts: { Item: { color: 'blue' } }
}
},
render: ({ props, parts }) => {
return (
<parts.Root>
<parts.Item active={true}>Item</parts.Item>
</parts.Root>
)
}
})
const html = renderToString(Component({}))
// Should have the class, but not the attribute
expect(html).toContain('class="NoVariantLeakage_Item active"')
expect(html).not.toContain('active="true"')
expect(html).not.toContain('active="false"')
})
test('combines root and part level variants', () => {
const Component = define('CombinedVariants', {
parts: {
NavItem: { base: 'a' }
},
variants: {
theme: {
dark: {
backgroundColor: 'black',
parts: { NavItem: { color: 'white' } }
}
},
active: {
parts: { NavItem: { fontWeight: 700 } }
}
},
render: ({ props, parts }) => {
return (
<parts.Root>
<parts.NavItem active={true}>Active Link</parts.NavItem>
<parts.NavItem active={false}>Inactive Link</parts.NavItem>
</parts.Root>
)
}
})
const html = renderToString(Component({ theme: 'dark' }))
// Root should have theme variant
expect(html).toContain('CombinedVariants theme-dark')
// Active NavItem should have both theme and active classes
expect(html).toContain('CombinedVariants_NavItem theme-dark active')
// Inactive NavItem should have only theme class
expect(html).toMatch(/CombinedVariants_NavItem theme-dark"[^>]*>Inactive/)
})
})
describe('edge cases', () => {
test('handles empty definition', () => {
const Component = define({})
@ -673,7 +768,7 @@ describe('edge cases', () => {
const Component = define({
variants: {
size: {
small: { layout: { padding: 8 } }
small: { padding: 8 }
}
}
})
@ -683,8 +778,8 @@ describe('edge cases', () => {
})
test('does not duplicate styles when registered multiple times', () => {
define('NoDuplicate', { layout: { width: 100 } })
define('NoDuplicate', { layout: { width: 200 } })
define('NoDuplicate', { width: 100 })
define('NoDuplicate', { width: 200 })
const styles = parseCSS(getStylesCSS())
// Should keep first value (??= operator)
@ -693,18 +788,18 @@ describe('edge cases', () => {
test('handles complex nested structures', () => {
define('ComplexNested', {
layout: { display: 'grid' },
display: 'grid',
parts: {
Container: { layout: { padding: 16 } },
Item: { look: { fontSize: 14 } }
Container: { padding: 16 },
Item: { fontSize: 14 }
},
variants: {
theme: {
dark: {
look: { backgroundColor: 'black' },
backgroundColor: 'black',
parts: {
Container: { look: { backgroundColor: '#222' } },
Item: { look: { color: 'white' } }
Container: { backgroundColor: '#222' },
Item: { color: 'white' }
}
}
}

View File

@ -1,37 +1,39 @@
export type TagDef = {
className?: string
base?: string
layout?: Layout
look?: Look
states?: Record<string, TagDef>,
parts?: Record<string, TagDef>
variants?: Record<string, TagDef | Record<string, TagDef>>
render?: (obj: any) => any
}
type Layout = {
alignContent?: string
alignItems?: string
alignSelf?: string
// layout-related
alignContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch' | 'start' | 'end' | 'baseline' | string
alignItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | string
alignSelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | string
aspectRatio?: number | string
bottom?: number | string
display?: string
boxSizing?: 'content-box' | 'border-box'
columnGap?: number | string
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' | string
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell' | string
flex?: number | string
flexBasis?: number | string
flexDirection?: string
flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse'
flexGrow?: number
flexShrink?: number
flexWrap?: string
flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
gap?: number | string
gridAutoFlow?: string
gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense'
gridColumn?: string
gridGap?: number | string
gridRow?: string
gridTemplateColumns?: string
gridTemplateRows?: string
height?: number | string
justifyContent?: string
justifyItems?: string
justifySelf?: string
inset?: number | string
justifyContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'start' | 'end' | 'left' | 'right' | 'stretch' | string
justifyItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right' | string
justifySelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right' | string
left?: number | string
margin?: number | string
marginBottom?: number | string
@ -43,70 +45,125 @@ type Layout = {
minHeight?: number | string
minWidth?: number | string
order?: number
overflow?: string
overflowX?: string
overflowY?: string
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowX?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowY?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
padding?: number | string
paddingBottom?: number | string
paddingLeft?: number | string
paddingRight?: number | string
paddingTop?: number | string
position?: string
placeContent?: string
placeItems?: string
placeSelf?: string
position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'
right?: number | string
rowGap?: number | string
top?: number | string
verticalAlign?: 'baseline' | 'top' | 'middle' | 'bottom' | 'text-top' | 'text-bottom' | 'sub' | 'super' | string
width?: number | string
zIndex?: number
}
type Look = {
// visual/theme-related
animation?: string
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio' | string
backdropFilter?: string
background?: string
backgroundAttachment?: 'scroll' | 'fixed' | 'local'
backgroundClip?: 'border-box' | 'padding-box' | 'content-box' | 'text'
backgroundColor?: string
backgroundImage?: string
backgroundPosition?: string
backgroundRepeat?: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | 'space' | 'round'
backgroundSize?: 'auto' | 'cover' | 'contain' | string
border?: string
borderBottom?: string
borderBottomColor?: string
borderBottomLeftRadius?: number | string
borderBottomRightRadius?: number | string
borderBottomStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderBottomWidth?: number | string
borderColor?: string
borderLeft?: string
borderLeftColor?: string
borderLeftStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderLeftWidth?: number | string
borderRadius?: number | string
borderRight?: string
borderStyle?: string
borderRightColor?: string
borderRightStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderRightWidth?: number | string
borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderTop?: string
borderTopColor?: string
borderTopLeftRadius?: number | string
borderTopRightRadius?: number | string
borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderTopWidth?: number | string
borderWidth?: number | string
boxShadow?: string
clipPath?: string
color?: string
cursor?: string
content?: string
cursor?: 'auto' | 'default' | 'none' | 'context-menu' | 'help' | 'pointer' | 'progress' | 'wait' | 'cell' | 'crosshair' | 'text' | 'vertical-text' | 'alias' | 'copy' | 'move' | 'no-drop' | 'not-allowed' | 'grab' | 'grabbing' | 'e-resize' | 'n-resize' | 'ne-resize' | 'nw-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'w-resize' | 'ew-resize' | 'ns-resize' | 'nesw-resize' | 'nwse-resize' | 'col-resize' | 'row-resize' | 'all-scroll' | 'zoom-in' | 'zoom-out' | string
filter?: string
fontFamily?: string
fontSize?: number | string
fontStyle?: string
fontWeight?: number | string
fontStyle?: 'normal' | 'italic' | 'oblique'
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold' | 'bolder' | 'lighter' | number
isolation?: 'auto' | 'isolate'
letterSpacing?: number | string
lineHeight?: number | string
mixBlendMode?: string
objectFit?: string
listStyle?: string
listStyleImage?: string
listStylePosition?: 'inside' | 'outside'
listStyleType?: 'none' | 'disc' | 'circle' | 'square' | 'decimal' | 'decimal-leading-zero' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha' | 'lower-greek' | 'lower-latin' | 'upper-latin' | string
mixBlendMode?: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity'
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
opacity?: number
outline?: string
outlineColor?: string
outlineOffset?: number | string
outlineStyle?: string
outlineStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'
outlineWidth?: number | string
pointerEvents?: string
textAlign?: string
pointerEvents?: 'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all'
resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline'
scrollBehavior?: 'auto' | 'smooth'
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end'
textDecoration?: string
textOverflow?: string
textDecorationColor?: string
textDecorationLine?: 'none' | 'underline' | 'overline' | 'line-through' | 'blink' | string
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'
textDecorationThickness?: number | string
textIndent?: number | string
textOverflow?: 'clip' | 'ellipsis' | string
textShadow?: string
textTransform?: string
textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | 'full-width' | 'full-size-kana'
transform?: string
transition?: string
userSelect?: string
visibility?: string
whiteSpace?: string
wordWrap?: string
overflowWrap?: string
userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all'
visibility?: 'visible' | 'hidden' | 'collapse'
whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' | 'break-spaces'
willChange?: 'auto' | 'scroll-position' | 'contents' | string
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
wordSpacing?: number | string
wordWrap?: 'normal' | 'break-word' | 'anywhere'
overflowWrap?: 'normal' | 'break-word' | 'anywhere'
}
export const NonStyleKeys = new Set([
'className',
'base',
'states',
'parts',
'variants',
'render',
'styles',
])
export const UnitlessProps = new Set([
'animationIterationCount',
'aspectRatio',
'columnCount',
'flex',
'flexGrow',