big stuff

This commit is contained in:
Chris Wanstrath 2025-12-27 21:14:58 -08:00
parent 399a398174
commit 4cf3e0e832
13 changed files with 773 additions and 152 deletions

View File

@ -1,5 +1,5 @@
import { define } from '../src' import { define } from '../src'
import { Layout, ExampleSection } from './helpers' import { ExampleSection } from './ssr/helpers'
const Button = define('Button', { const Button = define('Button', {
base: 'button', base: 'button',
@ -80,8 +80,8 @@ const ButtonRow = define('ButtonRow', {
alignItems: 'center', alignItems: 'center',
}) })
export const ButtonExamplesPage = () => ( export const ButtonExamplesContent = () => (
<Layout title="Forge Button Component Examples"> <>
<ExampleSection title="Intents"> <ExampleSection title="Intents">
<ButtonRow> <ButtonRow>
<Button intent="primary">Primary</Button> <Button intent="primary">Primary</Button>
@ -117,5 +117,5 @@ export const ButtonExamplesPage = () => (
<Button intent="ghost" disabled>Ghost Disabled</Button> <Button intent="ghost" disabled>Ghost Disabled</Button>
</ButtonRow> </ButtonRow>
</ExampleSection> </ExampleSection>
</Layout > </>
) )

View File

@ -1,20 +1,25 @@
import { define } from '../src' import { define } from '../src'
import { Layout, ExampleSection } from './helpers' import { ExampleSection } from './ssr/helpers'
const Tabs = define('Tabs', { const TabSwitcher = define('TabSwitcher', {
parts: {
Input: {
base: 'input',
display: 'none', // Hide radio inputs
},
TabBar: {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: '2px solid #e5e7eb', borderBottom: '2px solid #e5e7eb',
marginBottom: 24,
parts: { },
Tab: { TabLabel: {
base: 'button', base: 'label',
padding: '12px 24px', padding: '12px 24px',
position: 'relative', position: 'relative',
marginBottom: -2, marginBottom: -2,
background: 'transparent', background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent', borderBottom: '2px solid transparent',
color: '#6b7280', color: '#6b7280',
fontSize: 14, fontSize: 14,
@ -27,50 +32,76 @@ const Tabs = define('Tabs', {
color: '#111827', color: '#111827',
} }
} }
},
Content: {
display: 'none',
padding: 20,
background: '#f9fafb',
borderRadius: 8,
} }
}, },
variants: { render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) {
active: {
parts: {
Tab: {
color: '#3b82f6',
borderBottom: '2px solid #3b82f6',
}
}
}
},
render({ props, parts: { Root, Tab } }) {
return ( return (
<Root> <Root>
<script dangerouslySetInnerHTML={{ {/* Hidden radio inputs */}
__html: ` {props.tabs?.map((tab: any, index: number) => (
const ClickTab = (label) => console.log('Tab clicked:', label) <Input
`}} /> key={`input-${tab.id}`}
type="radio"
{props.items?.map((item: any) => ( id={`tab-${props.name}-${tab.id}`}
<Tab name={props.name || 'tabs'}
key={item.id} checked={index === 0}
active={item.active} />
onClick={`ClickTab("${item.label}")`}
>
{item.label}
</Tab>
))} ))}
{/* Tab labels */}
<TabBar>
{props.tabs?.map((tab: any) => (
<TabLabel key={`label-${tab.id}`} for={`tab-${props.name}-${tab.id}`}>
{tab.label}
</TabLabel>
))}
</TabBar>
{/* Tab content panels */}
{props.tabs?.map((tab: any) => (
<Content key={`content-${tab.id}`} class={`content-${props.name}-${tab.id}`}>
{tab.content}
</Content>
))}
{/* CSS to show active tab and content */}
<style dangerouslySetInnerHTML={{
__html: props.tabs?.map((tab: any) => `
input#tab-${props.name}-${tab.id}:checked + .TabBar label[for="tab-${props.name}-${tab.id}"],
input#tab-${props.name}-${tab.id}:checked ~ .TabBar label[for="tab-${props.name}-${tab.id}"] {
color: #3b82f6 !important;
border-bottom: 2px solid #3b82f6 !important;
}
input#tab-${props.name}-${tab.id}:checked ~ .content-${props.name}-${tab.id} {
display: block !important;
}
`).join('\n')
}} />
</Root> </Root>
) )
} }
}) })
const Pills = define('Pills', { const Pills = define('Pills', {
parts: {
Input: {
base: 'input',
display: 'none',
},
PillBar: {
display: 'flex', display: 'flex',
gap: 8, gap: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
},
parts: { PillLabel: {
Pill: { base: 'label',
base: 'button',
padding: '8px 16px', padding: '8px 16px',
background: '#f3f4f6', background: '#f3f4f6',
@ -91,49 +122,55 @@ const Pills = define('Pills', {
} }
}, },
variants: { render({ props, parts: { Root, Input, PillBar, PillLabel } }) {
active: {
parts: {
Pill: {
background: '#3b82f6',
color: 'white',
states: {
':hover': {
background: '#2563eb',
color: 'white',
}
}
}
}
}
},
render({ props, parts: { Root, Pill } }) {
return ( return (
<Root> <Root>
{props.items?.map((item: any) => ( {props.items?.map((item: any, index: number) => (
<Pill <Input
key={item.id} key={`input-${item.id}`}
active={item.active} type="radio"
onclick={() => console.log('Pill clicked:', item.label)} id={`pill-${props.name}-${item.id}`}
> name={props.name || 'pills'}
{item.label} checked={index === 0}
</Pill> />
))} ))}
<PillBar>
{props.items?.map((item: any) => (
<PillLabel key={`label-${item.id}`} for={`pill-${props.name}-${item.id}`}>
{item.label}
</PillLabel>
))}
</PillBar>
<style dangerouslySetInnerHTML={{
__html: props.items?.map((item: any) => `
input#pill-${props.name}-${item.id}:checked + .PillBar label[for="pill-${props.name}-${item.id}"],
input#pill-${props.name}-${item.id}:checked ~ .PillBar label[for="pill-${props.name}-${item.id}"] {
background: #3b82f6 !important;
color: white !important;
}
`).join('\n')
}} />
</Root> </Root>
) )
} }
}) })
const VerticalNav = define('VerticalNav', { const VerticalNav = define('VerticalNav', {
parts: {
Input: {
base: 'input',
display: 'none',
},
NavBar: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
width: 240, width: 240,
},
parts: { NavLabel: {
NavItem: { base: 'label',
base: 'a',
padding: '12px 16px', padding: '12px 16px',
display: 'flex', display: 'flex',
@ -145,7 +182,6 @@ const VerticalNav = define('VerticalNav', {
color: '#6b7280', color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500, fontWeight: 500,
textDecoration: 'none',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
@ -166,36 +202,37 @@ const VerticalNav = define('VerticalNav', {
} }
}, },
variants: { render({ props, parts: { Root, Input, NavBar, NavLabel, Icon } }) {
active: {
parts: {
NavItem: {
background: '#eff6ff',
color: '#3b82f6',
states: {
':hover': {
background: '#dbeafe',
color: '#2563eb',
}
}
}
}
}
},
render({ props, parts: { Root, NavItem, Icon } }) {
return ( return (
<Root> <Root>
{props.items?.map((item: any, index: number) => (
<Input
key={`input-${item.id}`}
type="radio"
id={`nav-${props.name}-${item.id}`}
name={props.name || 'nav'}
checked={index === 0}
/>
))}
<NavBar>
{props.items?.map((item: any) => ( {props.items?.map((item: any) => (
<NavItem <NavLabel key={`label-${item.id}`} for={`nav-${props.name}-${item.id}`}>
key={item.id}
active={item.active}
href={item.href || '#'}
>
{item.icon && <Icon>{item.icon}</Icon>} {item.icon && <Icon>{item.icon}</Icon>}
{item.label} {item.label}
</NavItem> </NavLabel>
))} ))}
</NavBar>
<style dangerouslySetInnerHTML={{
__html: props.items?.map((item: any) => `
input#nav-${props.name}-${item.id}:checked + .NavBar label[for="nav-${props.name}-${item.id}"],
input#nav-${props.name}-${item.id}:checked ~ .NavBar label[for="nav-${props.name}-${item.id}"] {
background: #eff6ff !important;
color: #3b82f6 !important;
}
`).join('\n')
}} />
</Root> </Root>
) )
} }
@ -256,8 +293,188 @@ const Breadcrumbs = define('Breadcrumbs', {
} }
}) })
export const NavigationExamplesPage = () => ( const Tabs = define('Tabs', {
<Layout title="Forge Navigation Examples"> 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"> <ExampleSection title="Tabs">
<Tabs items={[ <Tabs items={[
{ id: 1, label: 'Overview', active: true }, { id: 1, label: 'Overview', active: true },
@ -268,7 +485,7 @@ export const NavigationExamplesPage = () => (
</ExampleSection> </ExampleSection>
<ExampleSection title="Pills"> <ExampleSection title="Pills">
<Pills items={[ <SimplePills items={[
{ id: 1, label: 'All', active: true }, { id: 1, label: 'All', active: true },
{ id: 2, label: 'Active', active: false }, { id: 2, label: 'Active', active: false },
{ id: 3, label: 'Pending', active: false }, { id: 3, label: 'Pending', active: false },
@ -277,13 +494,13 @@ export const NavigationExamplesPage = () => (
</ExampleSection> </ExampleSection>
<ExampleSection title="Vertical Navigation"> <ExampleSection title="Vertical Navigation">
<VerticalNav items={[ <SimpleVerticalNav items={[
{ id: 1, label: 'Dashboard', icon: '📊', active: true, href: '#' }, { id: 1, label: 'Dashboard', icon: '📊', active: true },
{ id: 2, label: 'Projects', icon: '📁', active: false, href: '#' }, { id: 2, label: 'Projects', icon: '📁', active: false },
{ id: 3, label: 'Team', icon: '👥', active: false, href: '#' }, { id: 3, label: 'Team', icon: '👥', active: false },
{ id: 4, label: 'Calendar', icon: '📅', active: false, href: '#' }, { id: 4, label: 'Calendar', icon: '📅', active: false },
{ id: 5, label: 'Documents', icon: '📄', active: false, href: '#' }, { id: 5, label: 'Documents', icon: '📄', active: false },
{ id: 6, label: 'Settings', icon: '⚙️', active: false, href: '#' }, { id: 6, label: 'Settings', icon: '⚙️', active: false },
]} /> ]} />
</ExampleSection> </ExampleSection>
@ -295,5 +512,5 @@ export const NavigationExamplesPage = () => (
{ id: 4, label: 'Design Assets' }, { id: 4, label: 'Design Assets' },
]} /> ]} />
</ExampleSection> </ExampleSection>
</Layout> </>
) )

View File

@ -1,5 +1,5 @@
import { define } from '../src' import { define } from '../src'
import { Layout, ExampleSection } from './helpers' import { ExampleSection } from './ssr/helpers'
const UserProfile = define('UserProfile', { const UserProfile = define('UserProfile', {
base: 'div', base: 'div',
@ -167,9 +167,8 @@ const UserProfile = define('UserProfile', {
}, },
}) })
// Export the full example page export const ProfileExamplesContent = () => (
export const ProfileExamplesPage = () => ( <>
<Layout title="Forge Profile Examples">
<ExampleSection title="Default Profile"> <ExampleSection title="Default Profile">
<UserProfile <UserProfile
name="Sarah Chen" name="Sarah Chen"
@ -235,5 +234,5 @@ export const ProfileExamplesPage = () => (
posts={567} posts={567}
/> />
</ExampleSection> </ExampleSection>
</Layout> </>
) )

180
examples/spa/app.tsx Normal file
View File

@ -0,0 +1,180 @@
import { define } from '../../src'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
export const Main = define('SpaMain', {
base: 'div',
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',
states: {
hover: {
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"
/>
</ExamplesGrid>
</>
)
const ProfilePage = () => <ProfileExamplesContent />
const ButtonsPage = () => <ButtonExamplesContent />
const NavigationPage = () => <NavigationExamplesContent />
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 />
default:
return <P>404 Not Found</P>
}
}
export function App() {
return (
<Main>
<Container>
<Nav>
<a href="/" style="color: #3b82f6; text-decoration: none;">Home</a>
<Link href="/spa">SPA Examples</Link>
<Link href="/spa/profile">Profile</Link>
<Link href="/spa/buttons">Buttons</Link>
<Link href="/spa/navigation">Navigation</Link>
</Nav>
<div id="content">
{route(window.location.pathname)}
</div>
</Container>
</Main>
)
}

12
examples/spa/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forge SPA Examples</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/spa.js"></script>
</body>
</html>

19
examples/spa/index.tsx Normal file
View File

@ -0,0 +1,19 @@
import { render } from 'hono/jsx/dom'
import { App, route } from './app'
const root = document.getElementById('root')
// Initial render
if (root) {
render(<App />, root)
}
// On route change, only update the content div
function updateContent() {
const contentDiv = document.getElementById('content')
if (contentDiv)
render(route(window.location.pathname), contentDiv)
}
window.addEventListener('routechange', updateContent)
window.addEventListener('popstate', updateContent)

View File

@ -1,4 +1,4 @@
import { define, Styles } from '../src' import { define, Styles } from '../../src'
export const Body = define('Body', { export const Body = define('Body', {
base: 'body', base: 'body',
@ -43,6 +43,31 @@ export const ExampleSection = define('ExampleSection', {
} }
}) })
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',
states: {
hover: {
textDecoration: 'underline'
}
}
})
export const Layout = define({ export const Layout = define({
render({ props }) { render({ props }) {
return ( return (
@ -55,6 +80,13 @@ export const Layout = define({
</head> </head>
<Body> <Body>
<Container> <Container>
<Nav>
<NavLink href="/">Home</NavLink>
<NavLink href="/ssr">SSR Examples</NavLink>
<NavLink href="/ssr/profile">Profile</NavLink>
<NavLink href="/ssr/buttons">Buttons</NavLink>
<NavLink href="/ssr/navigation">Navigation</NavLink>
</Nav>
<Header>{props.title}</Header> <Header>{props.title}</Header>
{props.children} {props.children}
</Container> </Container>

117
examples/ssr/landing.tsx Normal file
View File

@ -0,0 +1,117 @@
import { define, Styles } from '../../src'
const Page = define('LandingPage', {
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('LandingContainer', {
textAlign: 'center',
color: 'white',
})
const Title = define('LandingTitle', {
base: 'h1',
fontSize: 48,
fontWeight: 700,
marginBottom: 50,
color: 'white',
})
const Subtitle = define('LandingSubtitle', {
base: 'p',
fontSize: 20,
marginBottom: 48,
color: 'rgba(255, 255, 255, 0.9)',
})
const ButtonGroup = define('LandingButtonGroup', {
display: 'flex',
gap: 50,
justifyContent: 'center',
flexWrap: 'wrap',
})
const ChoiceCard = define('LandingChoiceCard', {
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>
)

View File

@ -1,19 +1,22 @@
import { define } from '../src' import { define } from '../../src'
import { Layout } from './helpers' import { Layout } from './helpers'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
const P = define('P', { const P = define('SSR_P', {
color: '#6b7280', color: '#6b7280',
fontSize: 18, fontSize: 18,
marginBottom: 48, marginBottom: 48,
}) })
const ExamplesGrid = define('ExamplesGrid', { const ExamplesGrid = define('SSR_ExamplesGrid', {
display: 'grid', display: 'grid',
gap: 20, gap: 20,
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
}) })
const ExampleCard = define('ExampleCard', { const ExampleCard = define('SSR_ExampleCard', {
base: 'a', base: 'a',
background: 'white', background: 'white',
@ -44,9 +47,9 @@ const ExampleCard = define('ExampleCard', {
} }
}, },
render({ props: { title, desc }, parts: { Root, H2, P } }) { render({ props: { title, desc, ...rest }, parts: { Root, H2, P } }) {
return ( return (
<Root> <Root {...rest}>
<H2>{title}</H2> <H2>{title}</H2>
<P>{desc}</P> <P>{desc}</P>
</Root> </Root>
@ -59,20 +62,38 @@ export const IndexPage = () => (
<P>Explore component examples built with Forge</P> <P>Explore component examples built with Forge</P>
<ExamplesGrid> <ExamplesGrid>
<ExampleCard href="/profile" <ExampleCard href="/ssr/profile"
title="Profile Card" title="Profile Card"
desc="User profile component with variants for size, theme, and verified status" desc="User profile component with variants for size, theme, and verified status"
/> />
<ExampleCard href="/buttons" <ExampleCard href="/ssr/buttons"
title="Buttons" title="Buttons"
desc="Button component with intent, size, and disabled variants" desc="Button component with intent, size, and disabled variants"
/> />
<ExampleCard href="/navigation" <ExampleCard href="/ssr/navigation"
title="Navigation" title="Navigation"
desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs" desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
/> />
</ExamplesGrid> </ExamplesGrid>
</Layout> </Layout>
) )
export const ButtonExamplesPage = () => (
<Layout title="Forge Button Component Examples">
<ButtonExamplesContent />
</Layout>
)
export const ProfileExamplesPage = () => (
<Layout title="Forge Profile Examples">
<ProfileExamplesContent />
</Layout>
)
export const NavigationExamplesPage = () => (
<Layout title="Forge Navigation Examples">
<NavigationExamplesContent />
</Layout>
)

View File

@ -3,8 +3,9 @@
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run --hot server.tsx", "dev": "bun build:spa && bun run --hot server.tsx",
"test": "bun test" "test": "bun test",
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -1,30 +1,31 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { IndexPage } from './examples/index' import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage } from './examples/ssr/pages'
import { ProfileExamplesPage } from './examples/profile' import { LandingPage } from './examples/ssr/landing'
import { ButtonExamplesPage } from './examples/button'
import { NavigationExamplesPage } from './examples/navigation'
import { styles, stylesToCSS } from './src' import { styles, stylesToCSS } from './src'
const app = new Hono() const app = new Hono()
app.get('/', c => { app.get('/', c => c.html(<LandingPage />))
return c.html(<IndexPage />)
})
app.get('/profile', c => { app.get('/ssr', c => c.html(<IndexPage />))
return c.html(<ProfileExamplesPage />)
})
app.get('/buttons', c => { app.get('/ssr/profile', c => c.html(<ProfileExamplesPage />))
return c.html(<ButtonExamplesPage />)
})
app.get('/navigation', c => { app.get('/ssr/buttons', c => c.html(<ButtonExamplesPage />))
return c.html(<NavigationExamplesPage />)
})
app.get('/styles', c => { app.get('/ssr/navigation', c => c.html(<NavigationExamplesPage />))
return c.text(stylesToCSS(styles))
app.get('/styles', c => c.text(stylesToCSS(styles)))
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 { export default {

View File

@ -2,8 +2,27 @@ import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types' import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
export const styles: Record<string, Record<string, string>> = {} export const styles: Record<string, Record<string, string>> = {}
// Use w/ SSR: <Styles/>
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} /> export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} />
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(styles)
}
// turns style object into string CSS definition // turns style object into string CSS definition
export function stylesToCSS(styles: Record<string, Record<string, string>>): string { export function stylesToCSS(styles: Record<string, Record<string, string>>): string {
let out: string[] = [] let out: string[] = []
@ -152,6 +171,9 @@ function registerStyles(name: string, def: TagDef) {
} }
} }
} }
// In browser, inject styles into DOM immediately
injectStylesInBrowser()
} }
let anonComponents = 1 let anonComponents = 1

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext", "DOM"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",