FORGE
This commit is contained in:
commit
c561207128
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
105
CLAUDE.md
Normal file
105
CLAUDE.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Forge - Structured CSS Authoring
|
||||||
|
|
||||||
|
A typed, local, variant-driven way to author CSS. Compiles to real CSS but removes the chaos: no global conflicts, no selector gymnastics, no inline styles. Built for Hono JSX with SSR support.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
CSS is hostile to humans at scale: global namespace, no markup-to-definition link, requires inline styles for per-instance variance, silent conflicts, selector complexity.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
- **Local styles** - Attached via generated class names, not strings
|
||||||
|
- **Parts** - Named sub-components replace selectors (no `.Button > .Icon` nonsense)
|
||||||
|
- **Variants** - Typed parameters replace inline styles (no `style={{ color: x }}`)
|
||||||
|
- **Deterministic** - Known merge order, dev warnings for conflicts
|
||||||
|
- **Compiles to CSS** - Not a new language, not runtime CSS-in-JS, just organized CSS generation
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
**`define(name?, def)`** - Creates a styled component. Returns a component function.
|
||||||
|
- Accepts CSS properties in camelCase (auto-converts to kebab-case)
|
||||||
|
- Numbers auto-converted to `px` (except unitless props like `opacity`, `zIndex`)
|
||||||
|
- Generates CSS classes and registers styles globally
|
||||||
|
|
||||||
|
**Parts** - Sub-components within a component (e.g., Header, Body, Footer)
|
||||||
|
- Defined via `parts: { PartName: { ...styles } }`
|
||||||
|
- Accessible in render as `parts.PartName`
|
||||||
|
- Generate classes like `ComponentName_PartName`
|
||||||
|
|
||||||
|
**Variants** - Conditional styling based on props
|
||||||
|
- Boolean: `variants: { active: { color: 'blue' } }` → `<Component active />`
|
||||||
|
- Keyed: `variants: { size: { small: {...}, large: {...} } }` → `<Component size="small" />`
|
||||||
|
- Work on both root and parts
|
||||||
|
- Generate classes like `ComponentName.variant-key`
|
||||||
|
|
||||||
|
**States** - Pseudo-selectors like hover, focus
|
||||||
|
- `states: { hover: { background: 'blue' } }` → `.Class:hover { ... }`
|
||||||
|
|
||||||
|
**Custom Render** - Override default rendering
|
||||||
|
- `render({ props, parts }) { return <parts.Root>...</parts.Root> }`
|
||||||
|
- Compose parts manually, pass props through
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `src/index.tsx` - Main implementation (`define`, `Styles`, CSS generation)
|
||||||
|
- `src/types.ts` - `TagDef` type with all CSS properties, helper sets
|
||||||
|
- **`examples/`** - **REFERENCE THESE** for real-world usage patterns:
|
||||||
|
- `helpers.tsx` - Layout wrapper, reusable components (Body, Header, ThemeToggle)
|
||||||
|
- `index.tsx` - Landing page with grid, cards, parts, and custom render
|
||||||
|
- `button.tsx` - Button variants (intent, size, disabled)
|
||||||
|
- `profile.tsx` - Complex component with multiple parts and variants
|
||||||
|
- `navigation.tsx` - Tabs, pills, breadcrumbs, vertical nav patterns
|
||||||
|
- `src/tests/` - Comprehensive test suite with test helpers
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
- **Static CSS generation** - CSS created at component definition time, not runtime
|
||||||
|
- **Global styles registry** - `styles` object stores all CSS as plain objects
|
||||||
|
- **`Styles` component** - Renders `<style>` tag with all registered CSS (include in HTML `<head>`)
|
||||||
|
- **Real CSS classes** - Generates actual CSS selectors, not inline styles or CSS-in-JS
|
||||||
|
- **Class naming**:
|
||||||
|
- Root: `ComponentName`
|
||||||
|
- Parts: `ComponentName_PartName`
|
||||||
|
- Variants: `ComponentName.variant-key` or just `variant` for boolean
|
||||||
|
- States: `.ClassName:state`
|
||||||
|
- **No duplicate names** - Throws if same name registered twice
|
||||||
|
- **Anonymous components** - Auto-named `Def1`, `Def2`, etc. when name omitted
|
||||||
|
|
||||||
|
## Usage Pattern
|
||||||
|
|
||||||
|
**IMPORTANT: Check `examples/` for real-world patterns before writing new components.**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define } from 'forge'
|
||||||
|
|
||||||
|
export const Button = define('Button', {
|
||||||
|
base: 'button', // HTML tag (default: 'div')
|
||||||
|
padding: 20,
|
||||||
|
background: 'blue',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: { background: 'darkblue' }
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
danger: { background: 'red' }, // boolean
|
||||||
|
size: { // keyed
|
||||||
|
small: { padding: 10 },
|
||||||
|
large: { padding: 30 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// <Button>Click</Button>
|
||||||
|
// <Button danger>Danger!</Button>
|
||||||
|
// <Button size="large">Big</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
For complex patterns (parts with variants, custom render, states within variants), see:
|
||||||
|
- `examples/index.tsx` - ExampleCard with parts, custom render, nested variants
|
||||||
|
- `examples/profile.tsx` - Multi-part component with size/theme variants
|
||||||
|
- `examples/navigation.tsx` - Multiple component patterns (tabs, pills, breadcrumbs)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests use Bun's test runner. Helpers in `src/tests/test_helpers.ts` for rendering JSX to HTML strings and parsing CSS.
|
||||||
92
README.md
Normal file
92
README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Forge
|
||||||
|
|
||||||
|
## Why Forge?
|
||||||
|
|
||||||
|
CSS is powerful, but hostile.
|
||||||
|
|
||||||
|
### Problems with CSS
|
||||||
|
|
||||||
|
- Styles are **global and open** — anything can override anything.
|
||||||
|
- There’s **no link** between a class in markup and its definition.
|
||||||
|
- Inline styles exist because there’s **no structured way to vary styles per instance**.
|
||||||
|
- Overrides are silent — conflicts happen without feedback.
|
||||||
|
- Complex components require selector gymnastics and reach-in styling.
|
||||||
|
|
||||||
|
### What Forge Does Instead
|
||||||
|
|
||||||
|
- Styles are **local to components** and attached by generated handles, not strings.
|
||||||
|
- **Parts** give components named sub-targets without selectors.
|
||||||
|
- **Variants** replace inline styles with typed, declarative parameters.
|
||||||
|
- Style composition is **deterministic** (known merge order, last-wins).
|
||||||
|
- Overlapping changes are **warned about in dev**, not silently ignored.
|
||||||
|
|
||||||
|
### What Forge Is
|
||||||
|
|
||||||
|
- A typed, local, variant-driven way to author CSS.
|
||||||
|
- A system that optimizes for **people typing at a keyboard**, not selectors in a cascade.
|
||||||
|
|
||||||
|
### What Forge Is Not
|
||||||
|
|
||||||
|
- Not a new component model.
|
||||||
|
- Not a new language.
|
||||||
|
- Not a CSS replacement — it compiles _to_ CSS, but removes the chaos.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define } from "forge"
|
||||||
|
|
||||||
|
export const Button = define("button", {
|
||||||
|
base: "button",
|
||||||
|
|
||||||
|
padding: 20,
|
||||||
|
background: "blue",
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
kind: {
|
||||||
|
danger: { background: "red" },
|
||||||
|
warning: { background: "yellow" },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<Button>Click me</Button>
|
||||||
|
<Button kind="danger">Click me carefully</Button>
|
||||||
|
<Button kind="warning">Click me?</Button>
|
||||||
|
|
||||||
|
export const Profile = define("div", {
|
||||||
|
padding: 50,
|
||||||
|
background: "red",
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Header: { display: "flex" },
|
||||||
|
Avatar: { base: 'img', width: 50 },
|
||||||
|
Bio: { color: "gray" },
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: {
|
||||||
|
parts: { Avatar: { width: 20 }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Header, Avatar, Bio } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Header>
|
||||||
|
<Avatar src={props.pic} />
|
||||||
|
<Bio>{props.bio}</Bio>
|
||||||
|
</Header>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
import { Profile } from './whatever'
|
||||||
|
|
||||||
|
<Profile pic={user.pic} bio={user.bio} />
|
||||||
|
```
|
||||||
31
bun.lock
Normal file
31
bun.lock
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "forge",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.11.3",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
123
examples/button.tsx
Normal file
123
examples/button.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { createScope } from '../src'
|
||||||
|
import { ExampleSection } from './ssr/helpers'
|
||||||
|
|
||||||
|
const { define } = createScope('Button')
|
||||||
|
|
||||||
|
const Button = define('Root', {
|
||||||
|
base: 'button',
|
||||||
|
|
||||||
|
padding: "12px 24px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
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": {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
filter: 'brightness(1.05)'
|
||||||
|
},
|
||||||
|
":not(:disabled):active": {
|
||||||
|
transform: 'translateY(1px)',
|
||||||
|
boxShadow: '0 2px 3px rgba(0, 0, 0, 0.2)'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
intent: {
|
||||||
|
primary: {
|
||||||
|
background: "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 4px 6px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#374151",
|
||||||
|
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
background: "#ef4444",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 4px 6px rgba(239, 68, 68, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
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: {
|
||||||
|
padding: "8px 16px",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
padding: "16px 32px",
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonRow = define('Row', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ButtonExamplesContent = () => (
|
||||||
|
<>
|
||||||
|
<ExampleSection title="Intents">
|
||||||
|
<ButtonRow>
|
||||||
|
<Button intent="primary">Primary</Button>
|
||||||
|
<Button intent="secondary">Secondary</Button>
|
||||||
|
<Button intent="danger">Danger</Button>
|
||||||
|
<Button intent="ghost">Ghost</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
</ExampleSection >
|
||||||
|
|
||||||
|
<ExampleSection title="Sizes">
|
||||||
|
<ButtonRow>
|
||||||
|
<Button size="small">Small Button</Button>
|
||||||
|
<Button>Medium Button</Button>
|
||||||
|
<Button size="large">Large Button</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Sizes">
|
||||||
|
<ButtonRow>
|
||||||
|
<Button intent="primary" size="small">Small Primary</Button>
|
||||||
|
<Button intent="secondary" size="small">Small Secondary</Button>
|
||||||
|
<Button intent="danger" size="large">Large Danger</Button>
|
||||||
|
<Button intent="ghost" size="large">Large Ghost</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Disabled">
|
||||||
|
<ButtonRow>
|
||||||
|
<Button disabled>Default Disabled</Button>
|
||||||
|
<Button intent="primary" disabled>Primary Disabled</Button>
|
||||||
|
<Button intent="secondary" disabled>Secondary Disabled</Button>
|
||||||
|
<Button intent="danger" disabled>Danger Disabled</Button>
|
||||||
|
<Button intent="ghost" disabled>Ghost Disabled</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
</ExampleSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
252
examples/form.tsx
Normal file
252
examples/form.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { define } from '../src'
|
||||||
|
import { ExampleSection } from './ssr/helpers'
|
||||||
|
|
||||||
|
const Input = define('Input', {
|
||||||
|
base: 'input',
|
||||||
|
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: 16,
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'white',
|
||||||
|
color: '#111827',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
|
||||||
|
},
|
||||||
|
':disabled': {
|
||||||
|
background: '#f3f4f6',
|
||||||
|
color: '#9ca3af',
|
||||||
|
cursor: 'not-allowed'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
error: {
|
||||||
|
borderColor: '#ef4444',
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
borderColor: '#ef4444',
|
||||||
|
boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
borderColor: '#10b981',
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
borderColor: '#10b981',
|
||||||
|
boxShadow: '0 0 0 3px rgba(16, 185, 129, 0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Textarea = define('Textarea', {
|
||||||
|
base: 'textarea',
|
||||||
|
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: 16,
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'white',
|
||||||
|
color: '#111827',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 120,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
resize: 'vertical',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormGroup = define('FormGroup', {
|
||||||
|
marginBottom: 24,
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Label: {
|
||||||
|
base: 'label',
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: 8
|
||||||
|
},
|
||||||
|
Helper: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6b7280',
|
||||||
|
marginTop: 6
|
||||||
|
},
|
||||||
|
Error: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#ef4444',
|
||||||
|
marginTop: 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Label, Helper, Error } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{props.label && <Label>{props.label}</Label>}
|
||||||
|
{props.children}
|
||||||
|
{props.helper && <Helper>{props.helper}</Helper>}
|
||||||
|
{props.error && <Error>{props.error}</Error>}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Checkbox = define('Checkbox', {
|
||||||
|
parts: {
|
||||||
|
Input: {
|
||||||
|
base: 'input[type=checkbox]',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
cursor: 'pointer'
|
||||||
|
},
|
||||||
|
Label: {
|
||||||
|
base: 'label',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#374151',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: '#111827'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'@Input:disabled + &': {
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
color: '#9ca3af'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Input, Label } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Label>
|
||||||
|
<Input id={props.id} checked={props.checked} disabled={props.disabled} />
|
||||||
|
{props.label}
|
||||||
|
</Label>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormExamples = define('FormExamples', {
|
||||||
|
maxWidth: 600,
|
||||||
|
margin: '0 auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Button = define('FormButton', {
|
||||||
|
base: 'button',
|
||||||
|
|
||||||
|
padding: '12px 24px',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#2563eb'
|
||||||
|
},
|
||||||
|
':active': {
|
||||||
|
transform: 'translateY(1px)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
secondary: {
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#4b5563'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonGroup = define('FormButtonGroup', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 24
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FormExamplesContent = () => (
|
||||||
|
<FormExamples>
|
||||||
|
<ExampleSection title="Text Inputs">
|
||||||
|
<FormGroup label="Email" helper="We'll never share your email">
|
||||||
|
<Input type="email" placeholder="you@example.com" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Password">
|
||||||
|
<Input type="password" placeholder="Enter your password" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Disabled Input">
|
||||||
|
<Input value="This field is disabled" disabled />
|
||||||
|
</FormGroup>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Validation States">
|
||||||
|
<FormGroup label="Valid Email" helper="Looks good!">
|
||||||
|
<Input status="success" type="email" value="user@example.com" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Invalid Email" error="Please enter a valid email address">
|
||||||
|
<Input status="error" type="email" value="not-an-email" />
|
||||||
|
</FormGroup>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Textarea">
|
||||||
|
<FormGroup label="Bio" helper="Tell us about yourself">
|
||||||
|
<Textarea placeholder="Write something interesting..." />
|
||||||
|
</FormGroup>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Checkboxes">
|
||||||
|
<FormGroup>
|
||||||
|
<Checkbox id="cb1" label="I agree to the terms and conditions" checked />
|
||||||
|
<Checkbox id="cb2" label="Subscribe to newsletter" />
|
||||||
|
<Checkbox id="cb3" label="This option is disabled" disabled />
|
||||||
|
</FormGroup>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary">Reset</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</FormExamples>
|
||||||
|
)
|
||||||
516
examples/navigation.tsx
Normal file
516
examples/navigation.tsx
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
import { define } from '../src'
|
||||||
|
import { ExampleSection } from './ssr/helpers'
|
||||||
|
|
||||||
|
const TabSwitcher = define('TabSwitcher', {
|
||||||
|
parts: {
|
||||||
|
Input: {
|
||||||
|
base: 'input[type=radio]',
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
TabBar: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0,
|
||||||
|
borderBottom: '2px solid #e5e7eb',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
TabLabel: {
|
||||||
|
base: 'label',
|
||||||
|
|
||||||
|
padding: '12px 24px',
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: -2,
|
||||||
|
background: 'transparent',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked + &': {
|
||||||
|
color: '#3b82f6',
|
||||||
|
borderBottom: '2px solid #3b82f6'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Content: {
|
||||||
|
display: 'none',
|
||||||
|
padding: 20,
|
||||||
|
background: '#f9fafb',
|
||||||
|
borderRadius: 8,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked ~ &': {
|
||||||
|
display: 'block'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<TabBar>
|
||||||
|
{props.tabs?.map((tab: any, index: number) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
key={`input-${tab.id}`}
|
||||||
|
id={`${props.name}-${tab.id}`}
|
||||||
|
name={props.name || 'tabs'}
|
||||||
|
checked={index === 0}
|
||||||
|
/>
|
||||||
|
<TabLabel key={`label-${tab.id}`} for={`${props.name}-${tab.id}`}>
|
||||||
|
{tab.label}
|
||||||
|
</TabLabel>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{props.tabs?.map((tab: any) => (
|
||||||
|
<Content key={`content-${tab.id}`}>
|
||||||
|
{tab.content}
|
||||||
|
</Content>
|
||||||
|
))}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Pills = define('Pills', {
|
||||||
|
parts: {
|
||||||
|
Input: {
|
||||||
|
base: 'input[type=radio]',
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
PillBar: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
PillLabel: {
|
||||||
|
base: 'label',
|
||||||
|
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#e5e7eb',
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked + &': {
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white'
|
||||||
|
},
|
||||||
|
'@Input:checked + &:hover': {
|
||||||
|
background: '#2563eb'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Input, PillBar, PillLabel } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<PillBar>
|
||||||
|
{props.items?.map((item: any, index: number) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
key={`input-${item.id}`}
|
||||||
|
id={`${props.name}-${item.id}`}
|
||||||
|
name={props.name || 'pills'}
|
||||||
|
checked={index === 0}
|
||||||
|
/>
|
||||||
|
<PillLabel key={`label-${item.id}`} for={`${props.name}-${item.id}`}>
|
||||||
|
{item.label}
|
||||||
|
</PillLabel>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</PillBar>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const VerticalNav = define('VerticalNav', {
|
||||||
|
parts: {
|
||||||
|
Input: {
|
||||||
|
base: 'input[type=radio]',
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
NavBar: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
width: 240,
|
||||||
|
},
|
||||||
|
NavLabel: {
|
||||||
|
base: 'label',
|
||||||
|
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#f3f4f6',
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked + &': {
|
||||||
|
background: '#eff6ff',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
'@Input:checked + &:hover': {
|
||||||
|
background: '#dbeafe',
|
||||||
|
color: '#2563eb'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Icon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 18,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Input, NavBar, NavLabel, Icon } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<NavBar>
|
||||||
|
{props.items?.map((item: any, index: number) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
key={`input-${item.id}`}
|
||||||
|
id={`${props.name}-${item.id}`}
|
||||||
|
name={props.name || 'nav'}
|
||||||
|
checked={index === 0}
|
||||||
|
/>
|
||||||
|
<NavLabel key={`label-${item.id}`} for={`${props.name}-${item.id}`}>
|
||||||
|
{item.icon && <Icon>{item.icon}</Icon>}
|
||||||
|
{item.label}
|
||||||
|
</NavLabel>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</NavBar>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Breadcrumbs = define('Breadcrumbs', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Item: {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: '#3b82f6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Separator: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: 14,
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
Current: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Item, Separator, Current } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{props.items?.map((item: any, index: number) => (
|
||||||
|
<>
|
||||||
|
{index === props.items.length - 1 ? (
|
||||||
|
<Current key={item.id}>{item.label}</Current>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Item key={item.id} href={item.href || '#'}>
|
||||||
|
{item.label}
|
||||||
|
</Item>
|
||||||
|
<Separator>/</Separator>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Tabs = define('Tabs', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0,
|
||||||
|
borderBottom: '2px solid #e5e7eb',
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Tab: {
|
||||||
|
base: 'button',
|
||||||
|
padding: '12px 24px',
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: -2,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
parts: {
|
||||||
|
Tab: {
|
||||||
|
color: '#3b82f6',
|
||||||
|
borderBottom: '2px solid #3b82f6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Tab } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{props.items?.map((item: any) => (
|
||||||
|
<Tab key={item.id} active={item.active}>
|
||||||
|
{item.label}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SimplePills = define('SimplePills', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Pill: {
|
||||||
|
base: 'button',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 20,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#e5e7eb',
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
parts: {
|
||||||
|
Pill: {
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#2563eb',
|
||||||
|
color: 'white',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Pill } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{props.items?.map((item: any) => (
|
||||||
|
<Pill key={item.id} active={item.active}>
|
||||||
|
{item.label}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SimpleVerticalNav = define('SimpleVerticalNav', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
width: 240,
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
NavItem: {
|
||||||
|
base: 'button',
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#f3f4f6',
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Icon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 18,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
parts: {
|
||||||
|
NavItem: {
|
||||||
|
background: '#eff6ff',
|
||||||
|
color: '#3b82f6',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
background: '#dbeafe',
|
||||||
|
color: '#2563eb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, NavItem, Icon } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{props.items?.map((item: any) => (
|
||||||
|
<NavItem key={item.id} active={item.active}>
|
||||||
|
{item.icon && <Icon>{item.icon}</Icon>}
|
||||||
|
{item.label}
|
||||||
|
</NavItem>
|
||||||
|
))}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NavigationExamplesContent = () => (
|
||||||
|
<>
|
||||||
|
<ExampleSection title="Tabs">
|
||||||
|
<TabSwitcher
|
||||||
|
name="demo-tabs"
|
||||||
|
tabs={[
|
||||||
|
{ id: 'overview', label: 'Overview', content: <p>Overview content</p> },
|
||||||
|
{ id: 'analytics', label: 'Analytics', content: <p>Analytics content</p> },
|
||||||
|
{ id: 'reports', label: 'Reports', content: <p>Reports content</p> },
|
||||||
|
{ id: 'settings', label: 'Settings', content: <p>Settings content</p> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Pills">
|
||||||
|
<Pills
|
||||||
|
name="demo-pills"
|
||||||
|
items={[
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'active', label: 'Active' },
|
||||||
|
{ id: 'pending', label: 'Pending' },
|
||||||
|
{ id: 'archived', label: 'Archived' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Vertical Navigation">
|
||||||
|
<VerticalNav
|
||||||
|
name="demo-nav"
|
||||||
|
items={[
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||||
|
{ id: 'team', label: 'Team', icon: '👥' },
|
||||||
|
{ id: 'calendar', label: 'Calendar', icon: '📅' },
|
||||||
|
{ id: 'documents', label: 'Documents', icon: '📄' },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: '⚙️' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Breadcrumbs">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ id: 1, label: 'Home', href: '#' },
|
||||||
|
{ id: 2, label: 'Projects', href: '#' },
|
||||||
|
{ id: 3, label: 'Website Redesign', href: '#' },
|
||||||
|
{ id: 4, label: 'Design Assets' },
|
||||||
|
]} />
|
||||||
|
</ExampleSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
238
examples/profile.tsx
Normal file
238
examples/profile.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { define } from '../src'
|
||||||
|
import { ExampleSection } from './ssr/helpers'
|
||||||
|
|
||||||
|
const UserProfile = define('UserProfile', {
|
||||||
|
base: 'div',
|
||||||
|
|
||||||
|
padding: 24,
|
||||||
|
maxWidth: 600,
|
||||||
|
margin: "0 auto",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
Avatar: {
|
||||||
|
base: 'img',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: "50%",
|
||||||
|
objectFit: "cover",
|
||||||
|
border: "3px solid #e5e7eb",
|
||||||
|
},
|
||||||
|
Info: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
Name: {
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#111827",
|
||||||
|
},
|
||||||
|
Handle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6b7280",
|
||||||
|
},
|
||||||
|
Bio: {
|
||||||
|
marginBottom: 16,
|
||||||
|
width: "100%",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: "#374151",
|
||||||
|
wordWrap: "break-word",
|
||||||
|
},
|
||||||
|
Stats: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 24,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
},
|
||||||
|
Stat: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
StatValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#111827",
|
||||||
|
},
|
||||||
|
StatLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
compact: {
|
||||||
|
padding: 16,
|
||||||
|
maxWidth: 300,
|
||||||
|
parts: {
|
||||||
|
Avatar: { width: 48, height: 48 },
|
||||||
|
Name: { fontSize: 16 },
|
||||||
|
Bio: { fontSize: 13 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skinny: {
|
||||||
|
padding: 20,
|
||||||
|
maxWidth: 125,
|
||||||
|
parts: {
|
||||||
|
Header: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
Avatar: { width: 80, height: 80 },
|
||||||
|
Info: { flex: 0, width: "100%" },
|
||||||
|
Name: { textAlign: "center", fontSize: 18 },
|
||||||
|
Handle: { textAlign: "center" },
|
||||||
|
Bio: { textAlign: "center", fontSize: 13 },
|
||||||
|
Stats: {
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
Stat: { alignItems: "center" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
padding: 32,
|
||||||
|
maxWidth: 800,
|
||||||
|
parts: {
|
||||||
|
Avatar: { width: 96, height: 96 },
|
||||||
|
Name: { fontSize: 24 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
parts: {
|
||||||
|
Avatar: {
|
||||||
|
border: "3px solid #3b82f6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
dark: {
|
||||||
|
background: "#1f2937",
|
||||||
|
parts: {
|
||||||
|
Name: { color: "#f9fafb" },
|
||||||
|
Handle: { color: "#9ca3af" },
|
||||||
|
Bio: { color: "#d1d5db" },
|
||||||
|
Stats: { borderTop: "1px solid #374151" },
|
||||||
|
StatValue: { color: "#f9fafb" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Header, Avatar, Info, Name, Handle, Bio, Stats, Stat, StatValue, StatLabel } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Header>
|
||||||
|
<Avatar src={props.avatarUrl} alt={props.name} />
|
||||||
|
<Info>
|
||||||
|
<Name>
|
||||||
|
{props.name}
|
||||||
|
{props.verified && " ✓"}
|
||||||
|
</Name>
|
||||||
|
<Handle>@{props.username}</Handle>
|
||||||
|
</Info>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Bio>{props.bio}</Bio>
|
||||||
|
|
||||||
|
<Stats>
|
||||||
|
<Stat>
|
||||||
|
<StatValue>{props.followers?.toLocaleString() || 0}</StatValue>
|
||||||
|
<StatLabel>Followers</StatLabel>
|
||||||
|
</Stat>
|
||||||
|
<Stat>
|
||||||
|
<StatValue>{props.following?.toLocaleString() || 0}</StatValue>
|
||||||
|
<StatLabel>Following</StatLabel>
|
||||||
|
</Stat>
|
||||||
|
<Stat>
|
||||||
|
<StatValue>{props.posts?.toLocaleString() || 0}</StatValue>
|
||||||
|
<StatLabel>Posts</StatLabel>
|
||||||
|
</Stat>
|
||||||
|
</Stats>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ProfileExamplesContent = () => (
|
||||||
|
<>
|
||||||
|
<ExampleSection title="Default Profile">
|
||||||
|
<UserProfile
|
||||||
|
name="Sarah Chen"
|
||||||
|
username="sarahc"
|
||||||
|
avatarUrl="https://i.pravatar.cc/150?img=5"
|
||||||
|
bio="Designer & developer. Building beautiful interfaces. Based in SF."
|
||||||
|
followers={1234}
|
||||||
|
following={567}
|
||||||
|
posts={89}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Compact Variant">
|
||||||
|
<UserProfile
|
||||||
|
size="compact"
|
||||||
|
name="Alex Rivera"
|
||||||
|
username="arivera"
|
||||||
|
avatarUrl="https://i.pravatar.cc/150?img=12"
|
||||||
|
bio="Creative director. Coffee enthusiast."
|
||||||
|
followers={456}
|
||||||
|
following={234}
|
||||||
|
posts={45}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Skinny Variant">
|
||||||
|
<UserProfile
|
||||||
|
size="skinny"
|
||||||
|
name="Taylor Kim"
|
||||||
|
username="tkim"
|
||||||
|
avatarUrl="https://i.pravatar.cc/150?img=3"
|
||||||
|
bio="Minimalist designer."
|
||||||
|
followers={2890}
|
||||||
|
following={125}
|
||||||
|
posts={312}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Verified User (Dark Theme)">
|
||||||
|
<UserProfile
|
||||||
|
verified={true}
|
||||||
|
theme="dark"
|
||||||
|
name="Jordan Smith"
|
||||||
|
username="jordansmith"
|
||||||
|
avatarUrl="https://i.pravatar.cc/150?img=8"
|
||||||
|
bio="Tech entrepreneur. Angel investor. Sharing insights on startups and innovation."
|
||||||
|
followers={52300}
|
||||||
|
following={892}
|
||||||
|
posts={1240}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
|
||||||
|
<ExampleSection title="Large Verified Profile">
|
||||||
|
<UserProfile
|
||||||
|
size="large"
|
||||||
|
verified={true}
|
||||||
|
name="Morgan Taylor"
|
||||||
|
username="mtaylor"
|
||||||
|
avatarUrl="https://i.pravatar.cc/150?img=20"
|
||||||
|
bio="Product designer with 10 years of experience. Passionate about accessibility and inclusive design. Speaker at design conferences."
|
||||||
|
followers={8900}
|
||||||
|
following={234}
|
||||||
|
posts={567}
|
||||||
|
/>
|
||||||
|
</ExampleSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
202
examples/spa/app.tsx
Normal file
202
examples/spa/app.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { define } from '../../src'
|
||||||
|
import { ButtonExamplesContent } from '../button'
|
||||||
|
import { ProfileExamplesContent } from '../profile'
|
||||||
|
import { NavigationExamplesContent } from '../navigation'
|
||||||
|
import { FormExamplesContent } from '../form'
|
||||||
|
|
||||||
|
export const Main = define('SpaMain', {
|
||||||
|
base: 'div',
|
||||||
|
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '40px 20px',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
background: '#f3f4f6',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Container = define('SpaContainer', {
|
||||||
|
base: 'div',
|
||||||
|
|
||||||
|
maxWidth: 1200,
|
||||||
|
margin: '0 auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simple client-side router
|
||||||
|
const Link = define('Link', {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
color: '#3b82f6',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[aria-current]': {
|
||||||
|
color: '#1e40af',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root } }) {
|
||||||
|
const handleClick = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
window.history.pushState({}, '', props.href)
|
||||||
|
window.dispatchEvent(new Event('routechange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root {...props} onclick={handleClick}>
|
||||||
|
{props.children}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Nav = define('Nav', {
|
||||||
|
base: 'nav',
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
gap: 20,
|
||||||
|
marginBottom: 40,
|
||||||
|
padding: 20,
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const P = define('P', {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 48,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExamplesGrid = define('ExamplesGrid', {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 20,
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExampleCard = define('ExampleCard', {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
background: 'white',
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'block',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
H2: {
|
||||||
|
color: '#111827',
|
||||||
|
margin: '0 0 8px 0',
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
P: {
|
||||||
|
color: '#6b7280',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 14,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props: { title, desc, ...props }, parts: { Root, H2, P } }) {
|
||||||
|
const handleClick = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
window.history.pushState({}, '', props.href)
|
||||||
|
window.dispatchEvent(new Event('routechange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root {...props} onclick={handleClick}>
|
||||||
|
<H2>{title}</H2>
|
||||||
|
<P>{desc}</P>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const HomePage = () => (
|
||||||
|
<>
|
||||||
|
<P>Explore component examples built with Forge - Client-side SPA version</P>
|
||||||
|
|
||||||
|
<ExamplesGrid>
|
||||||
|
<ExampleCard href="/spa/profile"
|
||||||
|
title="Profile Card"
|
||||||
|
desc="User profile component with variants for size, theme, and verified status"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/spa/buttons"
|
||||||
|
title="Buttons"
|
||||||
|
desc="Button component with intent, size, and disabled variants"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/spa/navigation"
|
||||||
|
title="Navigation"
|
||||||
|
desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/spa/form"
|
||||||
|
title="Forms"
|
||||||
|
desc="Form inputs with validation states, checkboxes, textareas, and buttons"
|
||||||
|
/>
|
||||||
|
</ExamplesGrid>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProfilePage = () => <ProfileExamplesContent />
|
||||||
|
const ButtonsPage = () => <ButtonExamplesContent />
|
||||||
|
const NavigationPage = () => <NavigationExamplesContent />
|
||||||
|
const FormPage = () => <FormExamplesContent />
|
||||||
|
|
||||||
|
export function route(path: string) {
|
||||||
|
switch (path) {
|
||||||
|
case '/spa':
|
||||||
|
case '/spa/':
|
||||||
|
return <HomePage />
|
||||||
|
case '/spa/profile':
|
||||||
|
return <ProfilePage />
|
||||||
|
case '/spa/buttons':
|
||||||
|
return <ButtonsPage />
|
||||||
|
case '/spa/navigation':
|
||||||
|
return <NavigationPage />
|
||||||
|
case '/spa/form':
|
||||||
|
return <FormPage />
|
||||||
|
default:
|
||||||
|
return <P>404 Not Found</P>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main>
|
||||||
|
<Container>
|
||||||
|
<Nav>
|
||||||
|
<a href="/" style="color: #3b82f6; text-decoration: none; font-weight: 500;">Home</a>
|
||||||
|
<Link href="/spa" aria-current={path === '/spa' || path === '/spa/' ? 'page' : undefined}>SPA Examples</Link>
|
||||||
|
<Link href="/spa/profile" aria-current={path === '/spa/profile' ? 'page' : undefined}>Profile</Link>
|
||||||
|
<Link href="/spa/buttons" aria-current={path === '/spa/buttons' ? 'page' : undefined}>Buttons</Link>
|
||||||
|
<Link href="/spa/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link>
|
||||||
|
<Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link>
|
||||||
|
</Nav>
|
||||||
|
<div id="content">
|
||||||
|
{route(path)}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Main>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
examples/spa/index.html
Normal file
18
examples/spa/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="/main.css"/>
|
||||||
|
<title>Forge SPA Examples</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/spa.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
examples/spa/index.tsx
Normal file
19
examples/spa/index.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
import { App } from './app'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
if (root) {
|
||||||
|
render(<App />, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On route change, re-render the whole app to update nav state
|
||||||
|
function updateApp() {
|
||||||
|
if (root) {
|
||||||
|
render(<App />, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('routechange', updateApp)
|
||||||
|
window.addEventListener('popstate', updateApp)
|
||||||
109
examples/ssr/helpers.tsx
Normal file
109
examples/ssr/helpers.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { define, Styles } from '../../src'
|
||||||
|
|
||||||
|
export const Body = define('Body', {
|
||||||
|
base: 'body',
|
||||||
|
|
||||||
|
margin: 0,
|
||||||
|
padding: '40px 20px',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
background: '#f3f4f6',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
maxWidth: 1200,
|
||||||
|
margin: '0 auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Header = define('Header', {
|
||||||
|
base: 'h1',
|
||||||
|
|
||||||
|
marginBottom: 40,
|
||||||
|
color: '#111827'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExampleSection = define('ExampleSection', {
|
||||||
|
marginBottom: 40,
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Header: {
|
||||||
|
base: 'h2',
|
||||||
|
|
||||||
|
marginBottom: 16,
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 18
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render({ props, parts: { Root, Header } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Header>{props.title}</Header>
|
||||||
|
{props.children}
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Nav = define('SSR_Nav', {
|
||||||
|
base: 'nav',
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
gap: 20,
|
||||||
|
marginBottom: 40,
|
||||||
|
padding: 20,
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const NavLink = define('SSR_NavLink', {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
color: '#3b82f6',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[aria-current]': {
|
||||||
|
color: '#1e40af',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Layout = define({
|
||||||
|
render({ props }) {
|
||||||
|
const path = props.path || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{props.title}</title>
|
||||||
|
<link rel="stylesheet" href="/main.css" />
|
||||||
|
</head>
|
||||||
|
<Body>
|
||||||
|
<Container>
|
||||||
|
<Nav>
|
||||||
|
<NavLink href="/" aria-current={path === '/' ? 'page' : undefined}>Home</NavLink>
|
||||||
|
<NavLink href="/ssr" aria-current={path.startsWith('/ssr') && path !== '/ssr/profile' && path !== '/ssr/buttons' && path !== '/ssr/navigation' ? 'page' : undefined}>SSR Examples</NavLink>
|
||||||
|
<NavLink href="/ssr/profile" aria-current={path === '/ssr/profile' ? 'page' : undefined}>Profile</NavLink>
|
||||||
|
<NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</NavLink>
|
||||||
|
<NavLink href="/ssr/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
|
||||||
|
<NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink>
|
||||||
|
</Nav>
|
||||||
|
<Header>{props.title}</Header>
|
||||||
|
{props.children}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
119
examples/ssr/landing.tsx
Normal file
119
examples/ssr/landing.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { createScope, Styles } from '../../src'
|
||||||
|
|
||||||
|
const { define } = createScope('Landing')
|
||||||
|
|
||||||
|
const Page = define('Page', {
|
||||||
|
base: 'body',
|
||||||
|
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = define('Title', {
|
||||||
|
base: 'h1',
|
||||||
|
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 50,
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Subtitle = define('Subtitle', {
|
||||||
|
base: 'p',
|
||||||
|
|
||||||
|
fontSize: 20,
|
||||||
|
marginBottom: 48,
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonGroup = define('ButtonGroup', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 50,
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChoiceCard = define('ChoiceCard', {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
display: 'block',
|
||||||
|
padding: 40,
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: 16,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#111827',
|
||||||
|
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
minWidth: 250,
|
||||||
|
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
transform: 'translateY(-8px)',
|
||||||
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Icon: {
|
||||||
|
fontSize: 48,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
Title: {
|
||||||
|
base: 'h2',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#111827',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Icon, Title, Description } }) {
|
||||||
|
return (
|
||||||
|
<Root href={props.href}>
|
||||||
|
<Icon>{props.icon}</Icon>
|
||||||
|
<Title>{props.title}</Title>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LandingPage = () => (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Forge - Choose Your Rendering Mode</title>
|
||||||
|
<Styles />
|
||||||
|
</head>
|
||||||
|
<Page>
|
||||||
|
<Container>
|
||||||
|
<Title>Welcome to Forge</Title>
|
||||||
|
|
||||||
|
<ButtonGroup>
|
||||||
|
<ChoiceCard
|
||||||
|
href="/ssr"
|
||||||
|
icon="🖥️"
|
||||||
|
title="SSR Examples"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChoiceCard
|
||||||
|
href="/spa"
|
||||||
|
icon="⚡"
|
||||||
|
title="SPA Examples"
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Container>
|
||||||
|
</Page>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
111
examples/ssr/pages.tsx
Normal file
111
examples/ssr/pages.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { define } from '../../src'
|
||||||
|
import { Layout } from './helpers'
|
||||||
|
import { ButtonExamplesContent } from '../button'
|
||||||
|
import { ProfileExamplesContent } from '../profile'
|
||||||
|
import { NavigationExamplesContent } from '../navigation'
|
||||||
|
import { FormExamplesContent } from '../form'
|
||||||
|
|
||||||
|
const P = define('SSR_P', {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 48,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExamplesGrid = define('SSR_ExamplesGrid', {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 20,
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExampleCard = define('SSR_ExampleCard', {
|
||||||
|
base: 'a',
|
||||||
|
|
||||||
|
background: 'white',
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'block',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
H2: {
|
||||||
|
color: '#111827',
|
||||||
|
margin: '0 0 8px 0',
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
P: {
|
||||||
|
color: '#6b7280',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 14,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props: { title, desc, ...rest }, parts: { Root, H2, P } }) {
|
||||||
|
return (
|
||||||
|
<Root {...rest}>
|
||||||
|
<H2>{title}</H2>
|
||||||
|
<P>{desc}</P>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const IndexPage = ({ path }: any) => (
|
||||||
|
<Layout title="Forge Examples" path={path}>
|
||||||
|
<P>Explore component examples built with Forge</P>
|
||||||
|
|
||||||
|
<ExamplesGrid>
|
||||||
|
<ExampleCard href="/ssr/profile"
|
||||||
|
title="Profile Card"
|
||||||
|
desc="User profile component with variants for size, theme, and verified status"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/ssr/buttons"
|
||||||
|
title="Buttons"
|
||||||
|
desc="Button component with intent, size, and disabled variants"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/ssr/navigation"
|
||||||
|
title="Navigation"
|
||||||
|
desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExampleCard href="/ssr/form"
|
||||||
|
title="Forms"
|
||||||
|
desc="Form inputs with validation states, checkboxes, textareas, and buttons"
|
||||||
|
/>
|
||||||
|
</ExamplesGrid>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ButtonExamplesPage = ({ path }: any) => (
|
||||||
|
<Layout title="Forge Button Component Examples" path={path}>
|
||||||
|
<ButtonExamplesContent />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ProfileExamplesPage = ({ path }: any) => (
|
||||||
|
<Layout title="Forge Profile Examples" path={path}>
|
||||||
|
<ProfileExamplesContent />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NavigationExamplesPage = ({ path }: any) => (
|
||||||
|
<Layout title="Forge Navigation Examples" path={path}>
|
||||||
|
<NavigationExamplesContent />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FormExamplesPage = ({ path }: any) => (
|
||||||
|
<Layout title="Forge Form Examples" path={path}>
|
||||||
|
<FormExamplesContent />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "forge",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.11.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
server.tsx
Normal file
40
server.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage, FormExamplesPage } from './examples/ssr/pages'
|
||||||
|
import { LandingPage } from './examples/ssr/landing'
|
||||||
|
import { stylesToCSS } from './src'
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<LandingPage />))
|
||||||
|
|
||||||
|
app.get('/main.css', c => c.text(stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/ssr', c => c.html(<IndexPage path="/ssr" />))
|
||||||
|
|
||||||
|
app.get('/ssr/profile', c => c.html(<ProfileExamplesPage path="/ssr/profile" />))
|
||||||
|
|
||||||
|
app.get('/ssr/buttons', c => c.html(<ButtonExamplesPage path="/ssr/buttons" />))
|
||||||
|
|
||||||
|
app.get('/ssr/navigation', c => c.html(<NavigationExamplesPage path="/ssr/navigation" />))
|
||||||
|
|
||||||
|
app.get('/ssr/form', c => c.html(<FormExamplesPage path="/ssr/form" />))
|
||||||
|
|
||||||
|
app.get('/styles', c => c.text(stylesToCSS()))
|
||||||
|
|
||||||
|
app.get('/spa/*', async c => c.html(await Bun.file('./examples/spa/index.html').text()))
|
||||||
|
|
||||||
|
app.get('/spa.js', async c => {
|
||||||
|
const file = Bun.file('./dist/spa.js')
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port: 3300,
|
||||||
|
fetch: app.fetch,
|
||||||
|
}
|
||||||
235
src/index.tsx
Normal file
235
src/index.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import type { JSX } from 'hono/jsx'
|
||||||
|
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
|
||||||
|
|
||||||
|
export const styles: Record<string, Record<string, string>> = {}
|
||||||
|
|
||||||
|
// All CSS styles inside <style></style.
|
||||||
|
// Use w/ SSR: <Styles/>
|
||||||
|
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS() }} />
|
||||||
|
|
||||||
|
const isBrowser = typeof document !== 'undefined'
|
||||||
|
let styleElement: HTMLStyleElement | null = null
|
||||||
|
|
||||||
|
// automatically inject <style> tag into browser for SPA
|
||||||
|
function injectStylesInBrowser() {
|
||||||
|
if (!isBrowser) return
|
||||||
|
|
||||||
|
styleElement ??= document.getElementById('forge-styles') as HTMLStyleElement
|
||||||
|
|
||||||
|
if (!styleElement) {
|
||||||
|
styleElement = document.createElement('style')
|
||||||
|
styleElement.id = 'forge-styles'
|
||||||
|
document.head.appendChild(styleElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
styleElement.textContent = stylesToCSS()
|
||||||
|
}
|
||||||
|
|
||||||
|
// turns style object into string CSS definition
|
||||||
|
export function stylesToCSS(): string {
|
||||||
|
let out: string[] = []
|
||||||
|
|
||||||
|
for (const [selector, style] of Object.entries(styles)) {
|
||||||
|
if (Object.keys(style).length === 0) continue
|
||||||
|
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (variantName && variantKey) {
|
||||||
|
return cls + `.${variantName}-${variantKey}`
|
||||||
|
} else {
|
||||||
|
return cls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'fontSize' => 'font-size'
|
||||||
|
function camelToDash(name: string): string {
|
||||||
|
let out = ''
|
||||||
|
|
||||||
|
for (const letter of name.split(''))
|
||||||
|
out += letter.toUpperCase() === letter ? `-${letter.toLowerCase()}` : letter
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// turns a TagDef into a JSX style object
|
||||||
|
function makeStyle(def: TagDef) {
|
||||||
|
const style: Record<string, string> = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// turns a TagDef into a JSX component
|
||||||
|
function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<string, any>, partName?: string) {
|
||||||
|
const def = partName ? rootDef.parts?.[partName]! : rootDef
|
||||||
|
const base = def.base ?? 'div'
|
||||||
|
|
||||||
|
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
|
||||||
|
const tagName = base.split('[')[0]
|
||||||
|
const Tag = (tagName) as keyof JSX.IntrinsicElements
|
||||||
|
|
||||||
|
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
|
||||||
|
const baseAttrs: Record<string, string> = {}
|
||||||
|
const attrMatch = base.match(/\[([^\]]+)\]/)
|
||||||
|
if (attrMatch && attrMatch[1]) {
|
||||||
|
const attrStr = attrMatch[1]
|
||||||
|
const [attrName, attrValue] = attrStr.split('=')
|
||||||
|
if (attrName && attrValue) {
|
||||||
|
baseAttrs[attrName] = attrValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({ children, ...props }: { children: any, [key: string]: any }) => {
|
||||||
|
const classNames = [makeClassName(baseName, partName)]
|
||||||
|
|
||||||
|
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
|
||||||
|
// 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 <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensures 'hover' is ':hover'
|
||||||
|
function stateName(state: string): string {
|
||||||
|
return state.startsWith(':') ? state : `:${state}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register base styles, selectors, and states for a class
|
||||||
|
function registerClassStyles(name: string, className: string, def: TagDef) {
|
||||||
|
styles[className] ??= makeStyle(def)
|
||||||
|
|
||||||
|
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
|
||||||
|
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
|
||||||
|
selector = selector.replace('&', `.${className}`)
|
||||||
|
if (styles[selector]) throw `${selector} already defined!`
|
||||||
|
styles[selector] = makeStyle(selectorDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [state, style] of Object.entries(def.states ?? {}))
|
||||||
|
styles[`${className}${stateName(state)}`] = makeStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adds CSS styles for tag definition
|
||||||
|
function registerStyles(name: string, def: TagDef) {
|
||||||
|
const rootClassName = makeClassName(name)
|
||||||
|
registerClassStyles(name, rootClassName, def)
|
||||||
|
|
||||||
|
for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
|
||||||
|
const partClassName = makeClassName(name, partName)
|
||||||
|
registerClassStyles(name, partClassName, partDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
|
||||||
|
// 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}`
|
||||||
|
registerClassStyles(name, className, variantDef)
|
||||||
|
|
||||||
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
||||||
|
const basePartClassName = makeClassName(name, partName)
|
||||||
|
const partClassName = `${basePartClassName}.${variantName}`
|
||||||
|
registerClassStyles(name, partClassName, partDef)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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)
|
||||||
|
registerClassStyles(name, className, variantDef)
|
||||||
|
|
||||||
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
||||||
|
const partClassName = makeClassName(name, partName, variantName, variantKey)
|
||||||
|
registerClassStyles(name, partClassName, partDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In browser, inject styles into DOM immediately
|
||||||
|
injectStylesInBrowser()
|
||||||
|
}
|
||||||
|
|
||||||
|
// automatic names
|
||||||
|
let anonComponents = 1
|
||||||
|
|
||||||
|
// module-level scoping
|
||||||
|
export function createScope(scope: string) {
|
||||||
|
return {
|
||||||
|
define: (nameOrDef: string | TagDef, defIfNamed?: TagDef) => {
|
||||||
|
if (typeof nameOrDef === 'string')
|
||||||
|
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
|
||||||
|
else
|
||||||
|
return define(`${scope}Def${anonComponents++}`, nameOrDef as TagDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the main event
|
||||||
|
export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
|
||||||
|
const name = defIfNamed ? (nameOrDef as string) : `Def${anonComponents++}`
|
||||||
|
const def = defIfNamed ?? nameOrDef as TagDef
|
||||||
|
|
||||||
|
if (styles[name]) throw `${name} is already defined! Must use unique names.`
|
||||||
|
registerStyles(name, def)
|
||||||
|
|
||||||
|
return (props: Record<string, any>) => {
|
||||||
|
const parts: Record<string, Function> = {}
|
||||||
|
|
||||||
|
for (const [part] of Object.entries(def.parts ?? {}))
|
||||||
|
parts[part] = makeComponent(name, def, props, part)
|
||||||
|
|
||||||
|
parts.Root = makeComponent(name, def, props)
|
||||||
|
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcut so you only have to import one thing, if you want
|
||||||
|
define.Styles = Styles
|
||||||
983
src/tests/index.test.tsx
Normal file
983
src/tests/index.test.tsx
Normal file
|
|
@ -0,0 +1,983 @@
|
||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { define } from '../index'
|
||||||
|
import { renderToString, getStylesCSS, parseCSS } from './test_helpers'
|
||||||
|
|
||||||
|
describe('define - basic functionality', () => {
|
||||||
|
test('creates a component function', () => {
|
||||||
|
const Component = define({
|
||||||
|
display: 'flex'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(typeof Component).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('component returns a JSX element', () => {
|
||||||
|
const Component = define({
|
||||||
|
display: 'flex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = Component({})
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(typeof result).toBe('object')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies className to rendered element', () => {
|
||||||
|
const Component = define('MyComponent', {
|
||||||
|
display: 'flex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('class="MyComponent"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates unique anonymous component names', () => {
|
||||||
|
const Component1 = define({ display: 'flex' })
|
||||||
|
const Component2 = define({ display: 'block' })
|
||||||
|
|
||||||
|
const html1 = renderToString(Component1({}))
|
||||||
|
const html2 = renderToString(Component2({}))
|
||||||
|
|
||||||
|
// Should have different auto-generated names
|
||||||
|
expect(html1).toMatch(/class="Def\d+"/)
|
||||||
|
expect(html2).toMatch(/class="Def\d+"/)
|
||||||
|
expect(html1).not.toBe(html2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders default div element', () => {
|
||||||
|
const Component = define('DivTest', {
|
||||||
|
display: 'flex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<div')
|
||||||
|
expect(html).toContain('</div>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('respects custom base element', () => {
|
||||||
|
const Component = define('ButtonTest', {
|
||||||
|
base: 'button',
|
||||||
|
color: 'blue'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<button')
|
||||||
|
expect(html).toContain('</button>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes children through to component', () => {
|
||||||
|
const Component = define({})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Hello World' }))
|
||||||
|
expect(html).toContain('Hello World')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes additional props through to component', () => {
|
||||||
|
const Component = define({})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ id: 'test-id', 'data-test': 'value' }))
|
||||||
|
expect(html).toContain('id="test-id"')
|
||||||
|
expect(html).toContain('data-test="value"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - camelCase to kebab-case', () => {
|
||||||
|
test('converts camelCase properties to kebab-case', () => {
|
||||||
|
define('CamelTest', {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('flex-direction: column')
|
||||||
|
expect(css).toContain('align-items: center')
|
||||||
|
expect(css).toContain('justify-content: space-between')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles consecutive capital letters', () => {
|
||||||
|
define('ConsecutiveTest', {
|
||||||
|
backgroundColor: 'red',
|
||||||
|
borderRadius: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('background-color: red')
|
||||||
|
expect(css).toContain('border-radius: 5px')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - numeric values and units', () => {
|
||||||
|
test('adds px unit to numeric layout values', () => {
|
||||||
|
define('NumericTest', {
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
padding: 16,
|
||||||
|
margin: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['NumericTest']?.['width']).toBe('100px')
|
||||||
|
expect(styles['NumericTest']?.['height']).toBe('200px')
|
||||||
|
expect(styles['NumericTest']?.['padding']).toBe('16px')
|
||||||
|
expect(styles['NumericTest']?.['margin']).toBe('8px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves string values without adding px', () => {
|
||||||
|
define('StringTest', {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '1rem'
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['StringTest']?.['width']).toBe('100%')
|
||||||
|
expect(styles['StringTest']?.['height']).toBe('auto')
|
||||||
|
expect(styles['StringTest']?.['margin']).toBe('0 auto')
|
||||||
|
expect(styles['StringTest']?.['padding']).toBe('1rem')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not add px to unitless properties', () => {
|
||||||
|
define('UnitlessTest', {
|
||||||
|
flex: 1,
|
||||||
|
flexGrow: 2,
|
||||||
|
flexShrink: 1,
|
||||||
|
zIndex: 10,
|
||||||
|
order: 3,
|
||||||
|
opacity: 0.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.5
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['UnitlessTest']?.['flex']).toBe('1')
|
||||||
|
expect(styles['UnitlessTest']?.['flex-grow']).toBe('2')
|
||||||
|
expect(styles['UnitlessTest']?.['flex-shrink']).toBe('1')
|
||||||
|
expect(styles['UnitlessTest']?.['z-index']).toBe('10')
|
||||||
|
expect(styles['UnitlessTest']?.['order']).toBe('3')
|
||||||
|
expect(styles['UnitlessTest']?.['opacity']).toBe('0.5')
|
||||||
|
expect(styles['UnitlessTest']?.['font-weight']).toBe('700')
|
||||||
|
expect(styles['UnitlessTest']?.['line-height']).toBe('1.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles numeric zero values correctly', () => {
|
||||||
|
define('ZeroTest', {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
opacity: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ZeroTest']?.['margin']).toBe('0px')
|
||||||
|
expect(styles['ZeroTest']?.['padding']).toBe('0px')
|
||||||
|
expect(styles['ZeroTest']?.['z-index']).toBe('0')
|
||||||
|
expect(styles['ZeroTest']?.['opacity']).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - layout and look', () => {
|
||||||
|
test('generates CSS for layout properties', () => {
|
||||||
|
define('LayoutTest', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
padding: 20
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.LayoutTest')
|
||||||
|
expect(css).toContain('display: flex')
|
||||||
|
expect(css).toContain('flex-direction: column')
|
||||||
|
expect(css).toContain('gap: 16px')
|
||||||
|
expect(css).toContain('padding: 20px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for look properties', () => {
|
||||||
|
define('LookTest', {
|
||||||
|
color: 'blue',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.LookTest')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
expect(css).toContain('background-color: white')
|
||||||
|
expect(css).toContain('font-size: 16px')
|
||||||
|
expect(css).toContain('font-weight: 600')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('combines layout and look properties', () => {
|
||||||
|
define('CombinedTest', {
|
||||||
|
display: 'flex',
|
||||||
|
padding: 16,
|
||||||
|
color: 'blue',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
const combined = styles['CombinedTest']!
|
||||||
|
expect(combined['display']).toBe('flex')
|
||||||
|
expect(combined['padding']).toBe('16px')
|
||||||
|
expect(combined['color']).toBe('blue')
|
||||||
|
expect(combined['background-color']).toBe('white')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS generation - parts', () => {
|
||||||
|
test('generates separate CSS for each part', () => {
|
||||||
|
define('PartTest', {
|
||||||
|
display: 'flex',
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header', color: 'red', fontSize: 20 },
|
||||||
|
Body: { base: 'main', padding: 20 },
|
||||||
|
Footer: { base: 'footer', fontSize: 12 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['PartTest_Header']?.['color']).toBe('red')
|
||||||
|
expect(styles['PartTest_Header']?.['font-size']).toBe('20px')
|
||||||
|
expect(styles['PartTest_Body']?.['padding']).toBe('20px')
|
||||||
|
expect(styles['PartTest_Footer']?.['font-size']).toBe('12px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part className format is ComponentName_PartName', () => {
|
||||||
|
define('ComponentWithParts', {
|
||||||
|
parts: {
|
||||||
|
MyPart: { color: 'green' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.ComponentWithParts_MyPart')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('components with parts', () => {
|
||||||
|
test('creates part components accessible through render function', () => {
|
||||||
|
let capturedParts: any
|
||||||
|
|
||||||
|
const Component = define('PartRenderTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Body: { base: 'main' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
capturedParts = parts
|
||||||
|
return <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Component({})
|
||||||
|
|
||||||
|
expect(typeof capturedParts.Header).toBe('function')
|
||||||
|
expect(typeof capturedParts.Body).toBe('function')
|
||||||
|
expect(typeof capturedParts.Root).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part components render with correct className', () => {
|
||||||
|
const Component = define('PartClassTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Header>Header Content</parts.Header>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('class="PartClassTest_Header"')
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parts render with correct base elements', () => {
|
||||||
|
const Component = define('PartBaseTest', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Main: { base: 'main' },
|
||||||
|
Footer: { base: 'footer' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<parts.Header>Header</parts.Header>
|
||||||
|
<parts.Main>Main</parts.Main>
|
||||||
|
<parts.Footer>Footer</parts.Footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
expect(html).toContain('<main')
|
||||||
|
expect(html).toContain('<footer')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants - boolean variants', () => {
|
||||||
|
test('applies boolean variant class when true', () => {
|
||||||
|
const Component = define('BoolVariant', {
|
||||||
|
display: 'flex',
|
||||||
|
variants: {
|
||||||
|
primary: {
|
||||||
|
color: 'blue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ primary: true }))
|
||||||
|
// Boolean variants add just the variant name as a class
|
||||||
|
expect(html).toContain('class="BoolVariant primary"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not apply boolean variant class when false or absent', () => {
|
||||||
|
const Component = define('BoolVariantFalse', {
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
backgroundColor: 'green'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlFalse = renderToString(Component({ active: false }))
|
||||||
|
const htmlAbsent = renderToString(Component({}))
|
||||||
|
|
||||||
|
// When false or absent, variant class should not be added (check the class attribute)
|
||||||
|
expect(htmlFalse).toContain('class="BoolVariantFalse"')
|
||||||
|
expect(htmlFalse).not.toContain('class="BoolVariantFalse active')
|
||||||
|
expect(htmlAbsent).toContain('class="BoolVariantFalse"')
|
||||||
|
expect(htmlAbsent).not.toContain(' active')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for component with boolean variant', () => {
|
||||||
|
define('BoolVariantCSS', {
|
||||||
|
display: 'block',
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
backgroundColor: 'green'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.BoolVariantCSS')
|
||||||
|
expect(css).toContain('display: block')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants - string/enum variants', () => {
|
||||||
|
test('applies string variant class', () => {
|
||||||
|
const Component = define('StringVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 },
|
||||||
|
medium: { padding: 16 },
|
||||||
|
large: { padding: 24 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlSmall = renderToString(Component({ size: 'small' }))
|
||||||
|
const htmlLarge = renderToString(Component({ size: 'large' }))
|
||||||
|
|
||||||
|
expect(htmlSmall).toContain('class="StringVariant size-small"')
|
||||||
|
expect(htmlLarge).toContain('class="StringVariant size-large"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates CSS for each variant option', () => {
|
||||||
|
define('ColorVariant', {
|
||||||
|
variants: {
|
||||||
|
color: {
|
||||||
|
red: { color: 'red', backgroundColor: '#ffeeee' },
|
||||||
|
blue: { color: 'blue', backgroundColor: '#eeeeff' },
|
||||||
|
green: { color: 'green', backgroundColor: '#eeffee' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ColorVariant.color-red']?.['color']).toBe('red')
|
||||||
|
expect(styles['ColorVariant.color-blue']?.['color']).toBe('blue')
|
||||||
|
expect(styles['ColorVariant.color-green']?.['color']).toBe('green')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies multiple variant classes', () => {
|
||||||
|
const Component = define('MultiVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 },
|
||||||
|
large: { padding: 24 }
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
red: { color: 'red' },
|
||||||
|
blue: { color: 'blue' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small', color: 'blue' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
expect(html).toContain('color-blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores undefined variant values', () => {
|
||||||
|
const Component = define('UndefinedVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'nonexistent' }))
|
||||||
|
expect(html).toContain('class="UndefinedVariant"')
|
||||||
|
expect(html).not.toContain('size-nonexistent')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores non-variant props', () => {
|
||||||
|
const Component = define('NonVariantProps', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small', randomProp: 'value', id: 'test' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
expect(html).toContain('id="test"')
|
||||||
|
expect(html).toContain('randomProp="value"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants with parts', () => {
|
||||||
|
test('generates CSS for variant parts', () => {
|
||||||
|
define('VariantParts', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
dark: {
|
||||||
|
parts: {
|
||||||
|
Header: { color: 'white', backgroundColor: 'black' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
parts: {
|
||||||
|
Header: { color: 'black', backgroundColor: 'white' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['VariantParts_Header.theme-dark']?.['color']).toBe('white')
|
||||||
|
expect(styles['VariantParts_Header.theme-dark']?.['background-color']).toBe('black')
|
||||||
|
expect(styles['VariantParts_Header.theme-light']?.['color']).toBe('black')
|
||||||
|
expect(styles['VariantParts_Header.theme-light']?.['background-color']).toBe('white')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('part elements include variant classes', () => {
|
||||||
|
const Component = define('VariantPartClass', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
large: { padding: 20 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Header>Header Content</parts.Header>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'large' }))
|
||||||
|
expect(html).toContain('VariantPartClass_Header')
|
||||||
|
expect(html).toContain('size-large')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('custom render function', () => {
|
||||||
|
test('uses custom render when provided', () => {
|
||||||
|
const Component = define('CustomRender', {
|
||||||
|
display: 'flex',
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <div class="custom-wrapper">{props.children}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Content' }))
|
||||||
|
expect(html).toContain('class="custom-wrapper"')
|
||||||
|
expect(html).toContain('Content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom render receives props', () => {
|
||||||
|
let receivedProps: any
|
||||||
|
|
||||||
|
const Component = define({
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
receivedProps = props
|
||||||
|
return <parts.Root {...props}>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Component({ testProp: 'value', children: 'Test' })
|
||||||
|
|
||||||
|
expect(receivedProps.testProp).toBe('value')
|
||||||
|
expect(receivedProps.children).toBe('Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom render can compose parts', () => {
|
||||||
|
const Component = define('ComposeParts', {
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' },
|
||||||
|
Body: { base: 'main' },
|
||||||
|
Footer: { base: 'footer' }
|
||||||
|
},
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return (
|
||||||
|
<parts.Root>
|
||||||
|
<parts.Header>Header</parts.Header>
|
||||||
|
<parts.Body>{props.children}</parts.Body>
|
||||||
|
<parts.Footer>Footer</parts.Footer>
|
||||||
|
</parts.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ children: 'Main Content' }))
|
||||||
|
expect(html).toContain('<header')
|
||||||
|
expect(html).toContain('<main')
|
||||||
|
expect(html).toContain('<footer')
|
||||||
|
expect(html).toContain('Main Content')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Styles component', () => {
|
||||||
|
test('Styles is accessible via define.Styles', () => {
|
||||||
|
expect(typeof define.Styles).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles returns a style element', () => {
|
||||||
|
const result = define.Styles() as any
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.props.dangerouslySetInnerHTML).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles renders to HTML with CSS', () => {
|
||||||
|
define('StylesComp1', { width: 100 })
|
||||||
|
|
||||||
|
const html = renderToString(define.Styles())
|
||||||
|
expect(html).toContain('<style>')
|
||||||
|
expect(html).toContain('.StylesComp1')
|
||||||
|
expect(html).toContain('width: 100px')
|
||||||
|
expect(html).toContain('</style>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes CSS for all registered components', () => {
|
||||||
|
define('StylesComp2', { width: 100 })
|
||||||
|
define('StylesComp3', { height: 200 })
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesComp2')
|
||||||
|
expect(css).toContain('width: 100px')
|
||||||
|
expect(css).toContain('.StylesComp3')
|
||||||
|
expect(css).toContain('height: 200px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes variant CSS', () => {
|
||||||
|
define('StylesVariant', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 },
|
||||||
|
large: { padding: 24 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesVariant.size-small')
|
||||||
|
expect(css).toContain('padding: 8px')
|
||||||
|
expect(css).toContain('.StylesVariant.size-large')
|
||||||
|
expect(css).toContain('padding: 24px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Styles includes part CSS', () => {
|
||||||
|
define('StylesPart', {
|
||||||
|
parts: {
|
||||||
|
Header: { color: 'red' },
|
||||||
|
Body: { color: 'blue' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.StylesPart_Header')
|
||||||
|
expect(css).toContain('color: red')
|
||||||
|
expect(css).toContain('.StylesPart_Body')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('base with attributes', () => {
|
||||||
|
test('extracts element name from base with attributes', () => {
|
||||||
|
const Component = define('InputRadio', {
|
||||||
|
base: 'input[type=radio]',
|
||||||
|
display: 'block'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<input')
|
||||||
|
expect(html).toContain('type="radio"')
|
||||||
|
expect(html).toContain('class="InputRadio"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extracts multiple attribute formats', () => {
|
||||||
|
const Component = define('InputCheckbox', {
|
||||||
|
base: 'input[type=checkbox]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('type="checkbox"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('works without attributes', () => {
|
||||||
|
const Component = define('PlainInput', {
|
||||||
|
base: 'input',
|
||||||
|
padding: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
expect(html).toContain('<input')
|
||||||
|
expect(html).not.toContain('type=')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('base attributes can be overridden by props', () => {
|
||||||
|
const Component = define('OverridableInput', {
|
||||||
|
base: 'input[type=text]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ type: 'email' }))
|
||||||
|
expect(html).toContain('type="email"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectors with @ and &', () => {
|
||||||
|
test('generates CSS for selectors with @PartName', () => {
|
||||||
|
define('SelectorTest', {
|
||||||
|
parts: {
|
||||||
|
Input: { base: 'input[type=checkbox]', display: 'none' },
|
||||||
|
Label: {
|
||||||
|
base: 'label',
|
||||||
|
color: '#666',
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked + &': {
|
||||||
|
color: 'blue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.SelectorTest_Input:checked + .SelectorTest_Label')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectors support general sibling combinator ~', () => {
|
||||||
|
define('SiblingTest', {
|
||||||
|
parts: {
|
||||||
|
Trigger: { base: 'input[type=checkbox]' },
|
||||||
|
Content: {
|
||||||
|
display: 'none',
|
||||||
|
selectors: {
|
||||||
|
'@Trigger:checked ~ &': {
|
||||||
|
display: 'block'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.SiblingTest_Trigger:checked ~ .SiblingTest_Content')
|
||||||
|
expect(css).toContain('display: block')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectors can include pseudo-classes on &', () => {
|
||||||
|
define('PseudoTest', {
|
||||||
|
parts: {
|
||||||
|
Radio: { base: 'input[type=radio]', display: 'none' },
|
||||||
|
Label: {
|
||||||
|
base: 'label',
|
||||||
|
selectors: {
|
||||||
|
'@Radio:checked + &:hover': {
|
||||||
|
background: 'lightblue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.PseudoTest_Radio:checked + .PseudoTest_Label:hover')
|
||||||
|
expect(css).toContain('background: lightblue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectors work on root level', () => {
|
||||||
|
define('RootSelectorTest', {
|
||||||
|
color: 'black',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
color: 'blue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.RootSelectorTest:hover')
|
||||||
|
expect(css).toContain('color: blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports multiple selectors', () => {
|
||||||
|
define('MultiSelectorTest', {
|
||||||
|
parts: {
|
||||||
|
Input: { base: 'input[type=checkbox]' },
|
||||||
|
Label: {
|
||||||
|
selectors: {
|
||||||
|
'@Input:checked + &': { color: 'green' },
|
||||||
|
'@Input:disabled + &': { opacity: 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const css = getStylesCSS()
|
||||||
|
expect(css).toContain('.MultiSelectorTest_Input:checked + .MultiSelectorTest_Label')
|
||||||
|
expect(css).toContain('color: green')
|
||||||
|
expect(css).toContain('.MultiSelectorTest_Input:disabled + .MultiSelectorTest_Label')
|
||||||
|
expect(css).toContain('opacity: 0.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('handles empty definition', () => {
|
||||||
|
const Component = define({})
|
||||||
|
const html = renderToString(Component({}))
|
||||||
|
|
||||||
|
expect(html).toBeDefined()
|
||||||
|
expect(html).toMatch(/class="Def\d+"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles definition with only parts', () => {
|
||||||
|
const Component = define({
|
||||||
|
parts: {
|
||||||
|
Header: { base: 'header' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = Component({})
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles definition with only variants', () => {
|
||||||
|
const Component = define({
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: { padding: 8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({ size: 'small' }))
|
||||||
|
expect(html).toContain('size-small')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws error when defining duplicate component names', () => {
|
||||||
|
define('NoDuplicateTest', { width: 100 })
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
define('NoDuplicateTest', { width: 200 })
|
||||||
|
}).toThrow('NoDuplicateTest is already defined! Must use unique names.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles complex nested structures', () => {
|
||||||
|
define('ComplexNested', {
|
||||||
|
display: 'grid',
|
||||||
|
parts: {
|
||||||
|
Container: { padding: 16 },
|
||||||
|
Item: { fontSize: 14 }
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'black',
|
||||||
|
parts: {
|
||||||
|
Container: { backgroundColor: '#222' },
|
||||||
|
Item: { color: 'white' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = parseCSS(getStylesCSS())
|
||||||
|
expect(styles['ComplexNested']?.['display']).toBe('grid')
|
||||||
|
expect(styles['ComplexNested_Container']?.['padding']).toBe('16px')
|
||||||
|
expect(styles['ComplexNested_Item']?.['font-size']).toBe('14px')
|
||||||
|
expect(styles['ComplexNested.theme-dark']?.['background-color']).toBe('black')
|
||||||
|
expect(styles['ComplexNested_Container.theme-dark']?.['background-color']).toBe('#222')
|
||||||
|
expect(styles['ComplexNested_Item.theme-dark']?.['color']).toBe('white')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles JSX children correctly', () => {
|
||||||
|
const Component = define('ChildrenTest', {})
|
||||||
|
|
||||||
|
const html = renderToString(Component({
|
||||||
|
children: <span>Nested Element</span>
|
||||||
|
}))
|
||||||
|
expect(html).toContain('<span>Nested Element</span>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles multiple children', () => {
|
||||||
|
const Component = define('MultiChildren', {
|
||||||
|
render: ({ props, parts }) => {
|
||||||
|
return <parts.Root>{props.children}</parts.Root>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = renderToString(Component({
|
||||||
|
children: [
|
||||||
|
<div>First</div>,
|
||||||
|
<div>Second</div>
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
expect(html).toContain('First')
|
||||||
|
expect(html).toContain('Second')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/tests/test_helpers.ts
Normal file
32
src/tests/test_helpers.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { define } from '../index'
|
||||||
|
|
||||||
|
export function renderToString(jsx: any): string {
|
||||||
|
return jsx.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStylesCSS(): string {
|
||||||
|
const StylesComponent = define.Styles
|
||||||
|
const result = StylesComponent() as any
|
||||||
|
return result.props.dangerouslySetInnerHTML.__html as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSS(css: string): Record<string, Record<string, string>> {
|
||||||
|
const styles: Record<string, Record<string, string>> = {}
|
||||||
|
const classMatches = Array.from(css.matchAll(/\.([^\s{]+)\s*\{([^}]+)\}/g))
|
||||||
|
|
||||||
|
for (const match of classMatches) {
|
||||||
|
if (!match[1] || !match[2]) continue
|
||||||
|
|
||||||
|
const className = match[1]
|
||||||
|
const cssText = match[2]
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
305
src/types.ts
Normal file
305
src/types.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
export type TagDef = {
|
||||||
|
className?: string
|
||||||
|
base?: string
|
||||||
|
states?: Record<string, TagDef>,
|
||||||
|
selectors?: Record<string, TagDef>,
|
||||||
|
parts?: Record<string, TagDef>
|
||||||
|
variants?: Record<string, TagDef | Record<string, TagDef>>
|
||||||
|
render?: (obj: any) => any
|
||||||
|
alignContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch' | 'start' | 'end' | 'baseline'
|
||||||
|
alignItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end'
|
||||||
|
alignSelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end'
|
||||||
|
aspectRatio?: number | string
|
||||||
|
|
||||||
|
bottom?: number | string
|
||||||
|
left?: number | string
|
||||||
|
right?: number | string
|
||||||
|
top?: number | string
|
||||||
|
inset?: number | string
|
||||||
|
|
||||||
|
// logical positioning / sizing
|
||||||
|
insetBlock?: number | string
|
||||||
|
insetInline?: number | string
|
||||||
|
insetBlockStart?: number | string
|
||||||
|
insetBlockEnd?: number | string
|
||||||
|
insetInlineStart?: number | string
|
||||||
|
insetInlineEnd?: number | string
|
||||||
|
|
||||||
|
boxSizing?: 'content-box' | 'border-box'
|
||||||
|
|
||||||
|
columnGap?: number | string
|
||||||
|
rowGap?: number | string
|
||||||
|
gap?: number | string
|
||||||
|
|
||||||
|
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'
|
||||||
|
|
||||||
|
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell'
|
||||||
|
|
||||||
|
// float layout
|
||||||
|
float?: 'left' | 'right' | 'inline-start' | 'inline-end' | 'none'
|
||||||
|
clear?: 'left' | 'right' | 'both' | 'inline-start' | 'inline-end' | 'none'
|
||||||
|
|
||||||
|
flex?: number | string
|
||||||
|
flexBasis?: number | string
|
||||||
|
flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse'
|
||||||
|
flexGrow?: number
|
||||||
|
flexShrink?: number
|
||||||
|
flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
|
||||||
|
flexFlow?: string
|
||||||
|
|
||||||
|
gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense'
|
||||||
|
gridAutoColumns?: string
|
||||||
|
gridAutoRows?: string
|
||||||
|
gridColumn?: string
|
||||||
|
gridColumnStart?: string | number
|
||||||
|
gridColumnEnd?: string | number
|
||||||
|
gridRow?: string
|
||||||
|
gridRowStart?: string | number
|
||||||
|
gridRowEnd?: string | number
|
||||||
|
gridArea?: string
|
||||||
|
gridGap?: number | string
|
||||||
|
gridTemplateColumns?: string
|
||||||
|
gridTemplateRows?: string
|
||||||
|
gridTemplateAreas?: string
|
||||||
|
|
||||||
|
height?: number | string
|
||||||
|
width?: number | string
|
||||||
|
maxHeight?: number | string
|
||||||
|
maxWidth?: number | string
|
||||||
|
minHeight?: number | string
|
||||||
|
minWidth?: number | string
|
||||||
|
|
||||||
|
// logical sizes
|
||||||
|
blockSize?: number | string
|
||||||
|
inlineSize?: number | string
|
||||||
|
minBlockSize?: number | string
|
||||||
|
maxBlockSize?: number | string
|
||||||
|
minInlineSize?: number | string
|
||||||
|
maxInlineSize?: number | string
|
||||||
|
|
||||||
|
margin?: number | string
|
||||||
|
marginBottom?: number | string
|
||||||
|
marginLeft?: number | string
|
||||||
|
marginRight?: number | string
|
||||||
|
marginTop?: number | string
|
||||||
|
|
||||||
|
padding?: number | string
|
||||||
|
paddingBottom?: number | string
|
||||||
|
paddingLeft?: number | string
|
||||||
|
paddingRight?: number | string
|
||||||
|
paddingTop?: number | string
|
||||||
|
|
||||||
|
order?: number
|
||||||
|
|
||||||
|
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
|
||||||
|
overflowX?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
|
||||||
|
overflowY?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
|
||||||
|
overflowWrap?: 'normal' | 'break-word' | 'anywhere'
|
||||||
|
|
||||||
|
// overscroll / snap / scrolling ergonomics
|
||||||
|
overscrollBehavior?: 'auto' | 'contain' | 'none'
|
||||||
|
overscrollBehaviorX?: 'auto' | 'contain' | 'none'
|
||||||
|
overscrollBehaviorY?: 'auto' | 'contain' | 'none'
|
||||||
|
scrollBehavior?: 'auto' | 'smooth'
|
||||||
|
scrollSnapType?: 'none' | 'x' | 'y' | 'block' | 'inline' | 'both' | string
|
||||||
|
scrollSnapAlign?: 'none' | 'start' | 'end' | 'center'
|
||||||
|
scrollSnapStop?: 'normal' | 'always'
|
||||||
|
scrollMargin?: number | string
|
||||||
|
scrollMarginTop?: number | string
|
||||||
|
scrollMarginRight?: number | string
|
||||||
|
scrollMarginBottom?: number | string
|
||||||
|
scrollMarginLeft?: number | string
|
||||||
|
scrollPadding?: number | string
|
||||||
|
scrollPaddingTop?: number | string
|
||||||
|
scrollPaddingRight?: number | string
|
||||||
|
scrollPaddingBottom?: number | string
|
||||||
|
scrollPaddingLeft?: number | string
|
||||||
|
scrollbarWidth?: 'auto' | 'thin' | 'none'
|
||||||
|
scrollbarColor?: string
|
||||||
|
|
||||||
|
placeContent?: string
|
||||||
|
placeItems?: string
|
||||||
|
placeSelf?: string
|
||||||
|
|
||||||
|
position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'
|
||||||
|
|
||||||
|
justifyContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'start' | 'end' | 'left' | 'right' | 'stretch'
|
||||||
|
justifyItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right'
|
||||||
|
justifySelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right'
|
||||||
|
|
||||||
|
verticalAlign?: 'baseline' | 'top' | 'middle' | 'bottom' | 'text-top' | 'text-bottom' | 'sub' | 'super'
|
||||||
|
|
||||||
|
zIndex?: number
|
||||||
|
|
||||||
|
// visual/theme-related
|
||||||
|
animation?: string
|
||||||
|
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
// table-ish
|
||||||
|
borderCollapse?: 'collapse' | 'separate'
|
||||||
|
borderSpacing?: number | string
|
||||||
|
captionSide?: 'top' | 'bottom'
|
||||||
|
emptyCells?: 'show' | 'hide'
|
||||||
|
tableLayout?: 'auto' | 'fixed'
|
||||||
|
|
||||||
|
boxShadow?: string
|
||||||
|
clipPath?: string
|
||||||
|
|
||||||
|
color?: 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'
|
||||||
|
|
||||||
|
filter?: string
|
||||||
|
|
||||||
|
font?: string
|
||||||
|
fontFamily?: string
|
||||||
|
fontSize?: number | string
|
||||||
|
fontStyle?: 'normal' | 'italic' | 'oblique'
|
||||||
|
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold' | 'bolder' | 'lighter' | number
|
||||||
|
fontStretch?: string
|
||||||
|
fontVariant?: string
|
||||||
|
fontKerning?: 'auto' | 'normal' | 'none'
|
||||||
|
|
||||||
|
isolation?: 'auto' | 'isolate'
|
||||||
|
letterSpacing?: number | string
|
||||||
|
lineHeight?: number | 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'
|
||||||
|
|
||||||
|
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?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'
|
||||||
|
outlineWidth?: number | string
|
||||||
|
|
||||||
|
// form / selection / interaction
|
||||||
|
caretColor?: string
|
||||||
|
accentColor?: string
|
||||||
|
pointerEvents?: 'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all'
|
||||||
|
resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline'
|
||||||
|
touchAction?: 'auto' | 'none' | 'pan-x' | 'pan-y' | 'manipulation' | string
|
||||||
|
userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all'
|
||||||
|
|
||||||
|
// writing / bidi / hyphenation
|
||||||
|
direction?: 'ltr' | 'rtl'
|
||||||
|
writingMode?: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr' | string
|
||||||
|
unicodeBidi?: 'normal' | 'embed' | 'bidi-override' | 'isolate' | 'isolate-override' | 'plaintext'
|
||||||
|
hyphens?: 'none' | 'manual' | 'auto'
|
||||||
|
tabSize?: number | string
|
||||||
|
|
||||||
|
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end'
|
||||||
|
textDecoration?: string
|
||||||
|
textDecorationColor?: string
|
||||||
|
textDecorationLine?: 'none' | 'underline' | 'overline' | 'line-through' | 'blink'
|
||||||
|
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'
|
||||||
|
textDecorationThickness?: number | string
|
||||||
|
textUnderlineOffset?: number | string
|
||||||
|
textIndent?: number | string
|
||||||
|
textOverflow?: 'clip' | 'ellipsis' | string
|
||||||
|
textShadow?: string
|
||||||
|
textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | 'full-width' | 'full-size-kana'
|
||||||
|
whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' | 'break-spaces'
|
||||||
|
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
|
||||||
|
wordSpacing?: number | string
|
||||||
|
wordWrap?: 'normal' | 'break-word' | 'anywhere'
|
||||||
|
|
||||||
|
transform?: string
|
||||||
|
transformOrigin?: string
|
||||||
|
transformStyle?: 'flat' | 'preserve-3d'
|
||||||
|
perspective?: number | string
|
||||||
|
perspectiveOrigin?: string
|
||||||
|
backfaceVisibility?: 'visible' | 'hidden'
|
||||||
|
|
||||||
|
transition?: string
|
||||||
|
visibility?: 'visible' | 'hidden' | 'collapse'
|
||||||
|
willChange?: 'auto' | 'scroll-position' | 'contents'
|
||||||
|
|
||||||
|
// masks (if you want modern visual effects)
|
||||||
|
mask?: string
|
||||||
|
maskImage?: string
|
||||||
|
maskSize?: string
|
||||||
|
maskPosition?: string
|
||||||
|
maskRepeat?: string
|
||||||
|
|
||||||
|
// svg styling (if you want these supported)
|
||||||
|
fill?: string
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number | string
|
||||||
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||||
|
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||||
|
strokeDasharray?: number | string
|
||||||
|
strokeDashoffset?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NonStyleKeys = new Set([
|
||||||
|
'className',
|
||||||
|
'base',
|
||||||
|
'states',
|
||||||
|
'css',
|
||||||
|
'parts',
|
||||||
|
'variants',
|
||||||
|
'render',
|
||||||
|
'styles',
|
||||||
|
'selectors',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const UnitlessProps = new Set([
|
||||||
|
'animationIterationCount',
|
||||||
|
'aspectRatio',
|
||||||
|
'columnCount',
|
||||||
|
'flex',
|
||||||
|
'flexGrow',
|
||||||
|
'flexShrink',
|
||||||
|
'fontWeight',
|
||||||
|
'lineHeight',
|
||||||
|
'opacity',
|
||||||
|
'order',
|
||||||
|
'orphans',
|
||||||
|
'widows',
|
||||||
|
'zIndex'
|
||||||
|
])
|
||||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user