Compare commits
No commits in common. "main" and "fix/hmr-static-analysis" have entirely different histories.
main
...
fix/hmr-st
|
|
@ -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)
|
||||
- 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' } }` → `<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} />)
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Use `selectors` to write custom CSS selectors. Reference the current
|
||||
|
|
|
|||
29
bun.lock
29
bun.lock
|
|
@ -1,33 +1,40 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@because/forge",
|
||||
"name": "forge",
|
||||
"dependencies": {
|
||||
"hono": "^4.11.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"hono": "^4.11.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"snarkdown": "^2.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@types/prismjs": ["@types/prismjs@1.26.6", "https://npm.nose.space/@types/prismjs/-/prismjs-1.26.6.tgz", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
||||
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "https://npm.nose.space/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
"snarkdown": ["snarkdown@2.0.0", "https://npm.nose.space/snarkdown/-/snarkdown-2.0.0.tgz", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="],
|
||||
"snarkdown": ["snarkdown@2.0.0", "", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1140
docs/GUIDE.md
1140
docs/GUIDE.md
File diff suppressed because it is too large
Load Diff
|
|
@ -426,43 +426,6 @@ console.log(<Profile 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
|
||||
|
||||
Use \`selectors\` to write custom CSS selectors. Reference the current
|
||||
|
|
|
|||
18
package.json
18
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@because/forge",
|
||||
"version": "0.0.11",
|
||||
"name": "forge",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.tsx",
|
||||
"module": "src/index.tsx",
|
||||
|
|
@ -11,21 +11,21 @@
|
|||
"types": "./src/index.tsx"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src/index.tsx",
|
||||
"src/types.ts",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun build:spa && bun --watch run server.tsx",
|
||||
"dev": "bun build:spa && bun run --hot server.tsx",
|
||||
"test": "bun test",
|
||||
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"hono": "^4.11.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"snarkdown": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.11.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { type TagDef, type HTMLTag, UnitlessProps, NonStyleKeys } from './types'
|
||||
|
||||
type ForgeComponent<Parts = {}> =
|
||||
((props: Record<string, any>) => any) &
|
||||
{ [K in keyof Parts]: (props: Record<string, any>) => any }
|
||||
import type { JSX } from 'hono/jsx'
|
||||
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
|
||||
|
||||
export const styles: Record<string, Record<string, string>> = {}
|
||||
const themes: Record<string, Record<string, any>> = {}
|
||||
|
|
@ -12,6 +9,11 @@ export function clearStyles() {
|
|||
for (const key in themes) delete themes[key]
|
||||
}
|
||||
|
||||
// HMR support - clear styles when module is replaced
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => clearStyles())
|
||||
}
|
||||
|
||||
export function createTheme<const T extends Record<string, string | number>>(name: string, values: T): T {
|
||||
themes[name] = values as Record<string, string | number>
|
||||
return values
|
||||
|
|
@ -100,7 +102,7 @@ export function stylesToCSS(): string {
|
|||
for (const [selector, style] of Object.entries(styles)) {
|
||||
if (Object.keys(style).length === 0) continue
|
||||
|
||||
out.push(`${selector} { `)
|
||||
out.push(`${expandSelector(selector)} { `)
|
||||
for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b)))
|
||||
out.push(` ${name}: ${value};`)
|
||||
out.push(`}\n`)
|
||||
|
|
@ -109,6 +111,10 @@ export function stylesToCSS(): string {
|
|||
return out.join('\n')
|
||||
}
|
||||
|
||||
function expandSelector(selector: string): string {
|
||||
return selector.startsWith('.') ? selector : `.${selector}`
|
||||
}
|
||||
|
||||
// creates a CSS class name
|
||||
function makeClassName(baseName: string, partName?: string, variantName?: string, variantKey?: string): string {
|
||||
const cls = partName ? `${baseName}_${partName}` : baseName
|
||||
|
|
@ -149,7 +155,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
|
|||
|
||||
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
|
||||
const tagName = base.split('[')[0]
|
||||
const Tag = tagName as HTMLTag
|
||||
const Tag = (tagName) as keyof JSX.IntrinsicElements
|
||||
|
||||
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
|
||||
const baseAttrs: Record<string, string> = {}
|
||||
|
|
@ -191,16 +197,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
|
|||
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
|
||||
}
|
||||
|
||||
const finalProps = { class: classNames.join(' '), ...baseAttrs, ...props, children }
|
||||
|
||||
if ((finalProps as any).dangerouslySetInnerHTML) {
|
||||
const { children: _, ...rest } = finalProps
|
||||
return <Tag {...rest} />
|
||||
}
|
||||
|
||||
const content = (partName && def.render) ? def.render(finalProps) : children
|
||||
|
||||
return <Tag {...finalProps}>{content}</Tag>
|
||||
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +208,7 @@ function stateName(state: string): string {
|
|||
|
||||
// Register base styles, selectors, and states for a class
|
||||
function registerClassStyles(name: string, className: string, def: TagDef) {
|
||||
styles[`.${className}`] ??= makeStyle(def)
|
||||
styles[className] ??= makeStyle(def)
|
||||
|
||||
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
|
||||
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
|
||||
|
|
@ -221,7 +218,7 @@ function registerClassStyles(name: string, className: string, def: TagDef) {
|
|||
}
|
||||
|
||||
for (const [state, style] of Object.entries(def.states ?? {}))
|
||||
styles[`.${className}${stateName(state)}`] = makeStyle(style)
|
||||
styles[`${className}${stateName(state)}`] = makeStyle(style)
|
||||
}
|
||||
|
||||
// adds CSS styles for tag definition
|
||||
|
|
@ -249,12 +246,8 @@ 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
|
||||
|
|
@ -263,13 +256,8 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -284,7 +272,7 @@ export function createScope(scope: string) {
|
|||
return {
|
||||
define: (nameOrDef: string | TagDef, defIfNamed?: TagDef) => {
|
||||
if (typeof nameOrDef === 'string')
|
||||
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed!)
|
||||
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
|
||||
else
|
||||
return define(`${scope}${anonName(nameOrDef)}`, nameOrDef as TagDef)
|
||||
}
|
||||
|
|
@ -292,34 +280,22 @@ export function createScope(scope: string) {
|
|||
}
|
||||
|
||||
// the main event
|
||||
export function define<const T extends TagDef>(name: string, def: T): ForgeComponent<T['parts']>
|
||||
export function define<const T extends TagDef>(def: T): ForgeComponent<T['parts']>
|
||||
export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
|
||||
const def = defIfNamed ?? nameOrDef as TagDef
|
||||
const name = defIfNamed ? (nameOrDef as string) : anonName(def)
|
||||
|
||||
if (styles[`.${name}`]) throw `${name} is already defined! Must use unique names.`
|
||||
if (styles[name]) throw `${name} is already defined! Must use unique names.`
|
||||
registerStyles(name, def)
|
||||
|
||||
const currentProps: Record<string, any> = {}
|
||||
const Root = makeComponent(name, def, currentProps)
|
||||
const parts: Record<string, Function> = { Root }
|
||||
return (props: Record<string, any>) => {
|
||||
const parts: Record<string, Function> = {}
|
||||
|
||||
for (const [part] of Object.entries(def.parts ?? {}))
|
||||
parts[part] = makeComponent(name, def, currentProps, part)
|
||||
for (const [part] of Object.entries(def.parts ?? {}))
|
||||
parts[part] = makeComponent(name, def, props, part)
|
||||
|
||||
const component = (props: Record<string, any>) => {
|
||||
for (const key in currentProps) delete currentProps[key]
|
||||
Object.assign(currentProps, props)
|
||||
|
||||
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
|
||||
parts.Root = makeComponent(name, def, props)
|
||||
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.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
|
||||
|
|
@ -339,18 +315,5 @@ function tagName(base: string): string {
|
|||
return capitalized === 'A' ? 'Anchor' : capitalized
|
||||
}
|
||||
|
||||
// global CSS for resets, element defaults, pseudo-elements
|
||||
export function css(rules: Record<string, TagDef>) {
|
||||
for (const [selector, def] of Object.entries(rules)) {
|
||||
styles[selector] = makeStyle(def)
|
||||
|
||||
for (const [state, stateDef] of Object.entries(def.states ?? {}))
|
||||
styles[`${selector}${stateName(state)}`] = makeStyle(stateDef)
|
||||
}
|
||||
|
||||
injectStylesInBrowser()
|
||||
}
|
||||
|
||||
// shortcut so you only have to import one thing, if you want
|
||||
define.Styles = Styles
|
||||
define.css = css
|
||||
define.Styles = Styles
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
import { define, css } from '../index'
|
||||
import { define } from '../index'
|
||||
import { renderToString, getStylesCSS, parseCSS } from './test_helpers'
|
||||
|
||||
describe('define - basic functionality', () => {
|
||||
|
|
@ -558,82 +558,6 @@ describe('custom render function', () => {
|
|||
expect(html).toContain('<footer')
|
||||
expect(html).toContain('Main Content')
|
||||
})
|
||||
|
||||
test('part can have its own render function', () => {
|
||||
const Component = define('PartRender', {
|
||||
parts: {
|
||||
Icon: {
|
||||
base: 'span',
|
||||
color: 'blue',
|
||||
render: (props) => <>★ {props.children}</>
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Icon>star</parts.Icon>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({}))
|
||||
expect(html).toContain('★ star')
|
||||
expect(html).toContain('class="PartRender_Icon"')
|
||||
expect(html).toContain('<span') // base tag is preserved
|
||||
})
|
||||
|
||||
test('part render receives props including children', () => {
|
||||
let receivedProps: any
|
||||
|
||||
const Component = define('PartRenderProps', {
|
||||
parts: {
|
||||
Item: {
|
||||
base: 'li',
|
||||
render: (props) => {
|
||||
receivedProps = props
|
||||
return <>item: {props.children}</>
|
||||
}
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Item data-test="value">content</parts.Item>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({}))
|
||||
expect(receivedProps.class).toBe('PartRenderProps_Item')
|
||||
expect(receivedProps['data-test']).toBe('value')
|
||||
expect(receivedProps.children).toBe('content')
|
||||
expect(html).toContain('<li') // base tag preserved
|
||||
expect(html).toContain('item: content')
|
||||
})
|
||||
|
||||
test('part render works with variants', () => {
|
||||
const Component = define('PartRenderVariant', {
|
||||
variants: {
|
||||
size: {
|
||||
small: { fontSize: 12 },
|
||||
large: { fontSize: 24 }
|
||||
}
|
||||
},
|
||||
parts: {
|
||||
Label: {
|
||||
fontWeight: 'bold',
|
||||
render: (props) => <>Label: {props.children}</>
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Label>text</parts.Label>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({ size: 'large' }))
|
||||
expect(html).toContain('Label: text')
|
||||
expect(html).toContain('PartRenderVariant_Label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles component', () => {
|
||||
|
|
@ -1057,199 +981,3 @@ describe('edge cases', () => {
|
|||
expect(html).toContain('Second')
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
test('registers bare element selectors', () => {
|
||||
css({
|
||||
'body': { margin: 0, fontFamily: 'system-ui' }
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('body {')
|
||||
expect(out).toContain('font-family: system-ui')
|
||||
expect(out).toContain('margin: 0px')
|
||||
})
|
||||
|
||||
test('registers universal selector', () => {
|
||||
css({
|
||||
'*': { boxSizing: 'border-box' }
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('* {')
|
||||
expect(out).toContain('box-sizing: border-box')
|
||||
})
|
||||
|
||||
test('registers compound selectors', () => {
|
||||
css({
|
||||
'*, *::before, *::after': { boxSizing: 'border-box' }
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('*, *::before, *::after {')
|
||||
})
|
||||
|
||||
test('registers pseudo-element selectors', () => {
|
||||
css({
|
||||
'::-webkit-scrollbar': { width: 8 }
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('::-webkit-scrollbar {')
|
||||
expect(out).toContain('width: 8px')
|
||||
})
|
||||
|
||||
test('handles states on global selectors', () => {
|
||||
css({
|
||||
'a': {
|
||||
color: 'blue',
|
||||
states: {
|
||||
hover: { color: 'darkblue' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('a {')
|
||||
expect(out).toContain('a:hover {')
|
||||
expect(out).toContain('color: darkblue')
|
||||
})
|
||||
|
||||
test('registers multiple selectors at once', () => {
|
||||
css({
|
||||
'html': { lineHeight: 1.5 },
|
||||
'h1': { fontSize: 32 },
|
||||
'p': { margin: 0 }
|
||||
})
|
||||
|
||||
const out = getStylesCSS()
|
||||
expect(out).toContain('html {')
|
||||
expect(out).toContain('h1 {')
|
||||
expect(out).toContain('p {')
|
||||
})
|
||||
|
||||
test('accessible via define.css', () => {
|
||||
expect(define.css).toBe(css)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ export function parseCSS(css: string): Record<string, Record<string, string>> {
|
|||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
88
src/types.ts
88
src/types.ts
|
|
@ -31,24 +31,7 @@ export type TagDef = {
|
|||
rowGap?: number | string
|
||||
gap?: number | string
|
||||
|
||||
// multi-column layout
|
||||
columns?: string
|
||||
columnCount?: number | 'auto'
|
||||
columnWidth?: number | string
|
||||
columnRule?: string
|
||||
columnRuleColor?: string
|
||||
columnRuleStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
|
||||
columnRuleWidth?: number | string
|
||||
columnFill?: 'auto' | 'balance' | 'balance-all'
|
||||
columnSpan?: 'none' | 'all'
|
||||
|
||||
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'
|
||||
contentVisibility?: 'visible' | 'hidden' | 'auto'
|
||||
|
||||
// container queries
|
||||
container?: string
|
||||
containerType?: 'normal' | 'size' | 'inline-size'
|
||||
containerName?: string
|
||||
|
||||
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell'
|
||||
|
||||
|
|
@ -150,14 +133,6 @@ export type TagDef = {
|
|||
|
||||
// visual/theme-related
|
||||
animation?: string
|
||||
animationName?: string
|
||||
animationDuration?: string
|
||||
animationTimingFunction?: string
|
||||
animationDelay?: string
|
||||
animationIterationCount?: number | 'infinite'
|
||||
animationDirection?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
|
||||
animationFillMode?: 'none' | 'forwards' | 'backwards' | 'both'
|
||||
animationPlayState?: 'running' | 'paused'
|
||||
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio'
|
||||
backdropFilter?: string
|
||||
|
||||
|
|
@ -195,12 +170,6 @@ export type TagDef = {
|
|||
borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
|
||||
borderTopWidth?: number | string
|
||||
borderWidth?: number | string
|
||||
borderImage?: string
|
||||
borderImageSource?: string
|
||||
borderImageSlice?: number | string
|
||||
borderImageWidth?: number | string
|
||||
borderImageOutset?: number | string
|
||||
borderImageRepeat?: 'stretch' | 'repeat' | 'round' | 'space' | string
|
||||
|
||||
// table-ish
|
||||
borderCollapse?: 'collapse' | 'separate'
|
||||
|
|
@ -213,14 +182,10 @@ export type TagDef = {
|
|||
clipPath?: string
|
||||
|
||||
color?: string
|
||||
colorScheme?: 'normal' | 'light' | 'dark' | 'light dark' | string
|
||||
content?: string
|
||||
counterReset?: string
|
||||
counterIncrement?: 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'
|
||||
|
||||
filter?: string
|
||||
imageRendering?: 'auto' | 'crisp-edges' | 'pixelated'
|
||||
|
||||
font?: string
|
||||
fontFamily?: string
|
||||
|
|
@ -243,7 +208,6 @@ export type TagDef = {
|
|||
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'
|
||||
objectPosition?: string
|
||||
|
||||
opacity?: number
|
||||
|
||||
|
|
@ -283,37 +247,15 @@ export type TagDef = {
|
|||
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
|
||||
wordSpacing?: number | string
|
||||
wordWrap?: 'normal' | 'break-word' | 'anywhere'
|
||||
textRendering?: 'auto' | 'optimizeSpeed' | 'optimizeLegibility' | 'geometricPrecision'
|
||||
textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty' | 'stable'
|
||||
|
||||
// CSS shapes
|
||||
shapeOutside?: string
|
||||
shapeMargin?: number | string
|
||||
shapeImageThreshold?: number
|
||||
|
||||
// fragmentation / breaks
|
||||
breakBefore?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
|
||||
breakAfter?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
|
||||
breakInside?: 'auto' | 'avoid' | 'avoid-page' | 'avoid-column' | 'avoid-region'
|
||||
orphans?: number
|
||||
widows?: number
|
||||
|
||||
transform?: string
|
||||
transformOrigin?: string
|
||||
transformStyle?: 'flat' | 'preserve-3d'
|
||||
transformBox?: 'content-box' | 'border-box' | 'fill-box' | 'stroke-box' | 'view-box'
|
||||
rotate?: string
|
||||
scale?: string | number
|
||||
translate?: string
|
||||
perspective?: number | string
|
||||
perspectiveOrigin?: string
|
||||
backfaceVisibility?: 'visible' | 'hidden'
|
||||
|
||||
transition?: string
|
||||
transitionProperty?: string
|
||||
transitionDuration?: string
|
||||
transitionTimingFunction?: string
|
||||
transitionDelay?: string
|
||||
visibility?: 'visible' | 'hidden' | 'collapse'
|
||||
willChange?: 'auto' | 'scroll-position' | 'contents'
|
||||
|
||||
|
|
@ -358,34 +300,6 @@ export const UnitlessProps = new Set([
|
|||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'scale',
|
||||
'shapeImageThreshold',
|
||||
'widows',
|
||||
'zIndex'
|
||||
])
|
||||
|
||||
// All standard HTML element tag names
|
||||
export type HTMLTag =
|
||||
| 'a' | 'abbr' | 'address' | 'area' | 'article' | 'aside' | 'audio'
|
||||
| 'b' | 'base' | 'bdi' | 'bdo' | 'blockquote' | 'body' | 'br' | 'button'
|
||||
| 'canvas' | 'caption' | 'cite' | 'code' | 'col' | 'colgroup'
|
||||
| 'data' | 'datalist' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt'
|
||||
| 'em' | 'embed'
|
||||
| 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form'
|
||||
| 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html'
|
||||
| 'i' | 'iframe' | 'img' | 'input' | 'ins'
|
||||
| 'kbd'
|
||||
| 'label' | 'legend' | 'li' | 'link'
|
||||
| 'main' | 'map' | 'mark' | 'menu' | 'meta' | 'meter'
|
||||
| 'nav' | 'noscript'
|
||||
| 'object' | 'ol' | 'optgroup' | 'option' | 'output'
|
||||
| 'p' | 'picture' | 'pre' | 'progress'
|
||||
| 'q'
|
||||
| 'rp' | 'rt' | 'ruby'
|
||||
| 's' | 'samp' | 'script' | 'search' | 'section' | 'select' | 'slot' | 'small' | 'source' | 'span' | 'strong' | 'style' | 'sub' | 'summary' | 'sup'
|
||||
| 'table' | 'tbody' | 'td' | 'template' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'title' | 'tr' | 'track'
|
||||
| 'u' | 'ul'
|
||||
| 'var' | 'video'
|
||||
| 'wbr'
|
||||
// SVG elements
|
||||
| 'svg' | 'path' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'polyline' | 'rect' | 'g' | 'defs' | 'use' | 'text' | 'tspan' | 'image' | 'clipPath' | 'mask' | 'pattern' | 'linearGradient' | 'radialGradient' | 'stop' | 'filter' | 'feBlend' | 'feColorMatrix' | 'feGaussianBlur' | 'symbol'
|
||||
])
|
||||
Loading…
Reference in New Issue
Block a user