pretty cool

This commit is contained in:
Chris Wanstrath 2025-12-28 18:51:38 -08:00
parent 3c3ad71296
commit ad7922ee7a
9 changed files with 86 additions and 83 deletions

View File

@ -2,7 +2,7 @@
## Why Forge? ## Why Forge?
CSS is powerful, but hostile to humans at scale. CSS is powerful, but hostile.
### Problems with CSS ### Problems with CSS

View File

@ -34,7 +34,7 @@ const TabSwitcher = define('TabSwitcher', {
}, },
selectors: { selectors: {
'.TabSwitcher_Input:checked + &': { '@Input:checked + &': {
color: '#3b82f6', color: '#3b82f6',
borderBottom: '2px solid #3b82f6' borderBottom: '2px solid #3b82f6'
} }
@ -47,7 +47,7 @@ const TabSwitcher = define('TabSwitcher', {
borderRadius: 8, borderRadius: 8,
selectors: { selectors: {
'.TabSwitcher_Input:checked ~ &': { '@Input:checked ~ &': {
display: 'block' display: 'block'
} }
} }
@ -115,11 +115,11 @@ const Pills = define('Pills', {
}, },
selectors: { selectors: {
'.Pills_Input:checked + &': { '@Input:checked + &': {
background: '#3b82f6', background: '#3b82f6',
color: 'white' color: 'white'
}, },
'.Pills_Input:checked + &:hover': { '@Input:checked + &:hover': {
background: '#2563eb' background: '#2563eb'
} }
} }
@ -185,11 +185,11 @@ const VerticalNav = define('VerticalNav', {
}, },
selectors: { selectors: {
'.VerticalNav_Input:checked + &': { '@Input:checked + &': {
background: '#eff6ff', background: '#eff6ff',
color: '#3b82f6', color: '#3b82f6',
}, },
'.VerticalNav_Input:checked + &:hover': { '@Input:checked + &:hover': {
background: '#dbeafe', background: '#dbeafe',
color: '#2563eb' color: '#2563eb'
} }

View File

@ -6,6 +6,7 @@ import { NavigationExamplesContent } from '../navigation'
export const Main = define('SpaMain', { export const Main = define('SpaMain', {
base: 'div', base: 'div',
minHeight: '100%',
padding: '40px 20px', padding: '40px 20px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: '#f3f4f6', background: '#f3f4f6',
@ -24,6 +25,7 @@ const Link = define('Link', {
color: '#3b82f6', color: '#3b82f6',
textDecoration: 'none', textDecoration: 'none',
fontWeight: 500,
states: { states: {
hover: { hover: {
@ -31,6 +33,14 @@ const Link = define('Link', {
} }
}, },
selectors: {
'&[aria-current]': {
color: '#1e40af',
fontWeight: 600,
textDecoration: 'underline'
}
},
render({ props, parts: { Root } }) { render({ props, parts: { Root } }) {
const handleClick = (e: Event) => { const handleClick = (e: Event) => {
e.preventDefault() e.preventDefault()
@ -161,18 +171,20 @@ export function route(path: string) {
} }
export function App() { export function App() {
const path = window.location.pathname
return ( return (
<Main> <Main>
<Container> <Container>
<Nav> <Nav>
<a href="/" style="color: #3b82f6; text-decoration: none;">Home</a> <a href="/" style="color: #3b82f6; text-decoration: none; font-weight: 500;">Home</a>
<Link href="/spa">SPA Examples</Link> <Link href="/spa" aria-current={path === '/spa' || path === '/spa/' ? 'page' : undefined}>SPA Examples</Link>
<Link href="/spa/profile">Profile</Link> <Link href="/spa/profile" aria-current={path === '/spa/profile' ? 'page' : undefined}>Profile</Link>
<Link href="/spa/buttons">Buttons</Link> <Link href="/spa/buttons" aria-current={path === '/spa/buttons' ? 'page' : undefined}>Buttons</Link>
<Link href="/spa/navigation">Navigation</Link> <Link href="/spa/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link>
</Nav> </Nav>
<div id="content"> <div id="content">
{route(window.location.pathname)} {route(path)}
</div> </div>
</Container> </Container>
</Main> </Main>

View File

@ -4,6 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forge SPA Examples</title> <title>Forge SPA Examples</title>
<style>
html, body {
height: 100%;
margin: 0;
}
#root {
min-height: 100%;
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -8,12 +8,12 @@ if (root) {
render(<App />, root) render(<App />, root)
} }
// On route change, only update the content div // On route change, re-render the whole app to update nav state
function updateContent() { function updateApp() {
const contentDiv = document.getElementById('content') if (root) {
if (contentDiv) render(<App />, root)
render(route(window.location.pathname), contentDiv) }
} }
window.addEventListener('routechange', updateContent) window.addEventListener('routechange', updateApp)
window.addEventListener('popstate', updateContent) window.addEventListener('popstate', updateApp)

View File

@ -60,16 +60,27 @@ const NavLink = define('SSR_NavLink', {
color: '#3b82f6', color: '#3b82f6',
textDecoration: 'none', textDecoration: 'none',
fontWeight: 500,
states: { states: {
hover: { hover: {
textDecoration: 'underline' textDecoration: 'underline'
} }
},
selectors: {
'&[aria-current]': {
color: '#1e40af',
fontWeight: 600,
textDecoration: 'underline'
}
} }
}) })
export const Layout = define({ export const Layout = define({
render({ props }) { render({ props }) {
const path = props.path || ''
return ( return (
<html> <html>
<head> <head>
@ -81,11 +92,11 @@ export const Layout = define({
<Body> <Body>
<Container> <Container>
<Nav> <Nav>
<NavLink href="/">Home</NavLink> <NavLink href="/" aria-current={path === '/' ? 'page' : undefined}>Home</NavLink>
<NavLink href="/ssr">SSR Examples</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">Profile</NavLink> <NavLink href="/ssr/profile" aria-current={path === '/ssr/profile' ? 'page' : undefined}>Profile</NavLink>
<NavLink href="/ssr/buttons">Buttons</NavLink> <NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</NavLink>
<NavLink href="/ssr/navigation">Navigation</NavLink> <NavLink href="/ssr/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
</Nav> </Nav>
<Header>{props.title}</Header> <Header>{props.title}</Header>
{props.children} {props.children}

View File

@ -57,8 +57,8 @@ const ExampleCard = define('SSR_ExampleCard', {
} }
}) })
export const IndexPage = () => ( export const IndexPage = ({ path }: any) => (
<Layout title="Forge Examples"> <Layout title="Forge Examples" path={path}>
<P>Explore component examples built with Forge</P> <P>Explore component examples built with Forge</P>
<ExamplesGrid> <ExamplesGrid>
@ -80,20 +80,20 @@ export const IndexPage = () => (
</Layout> </Layout>
) )
export const ButtonExamplesPage = () => ( export const ButtonExamplesPage = ({ path }: any) => (
<Layout title="Forge Button Component Examples"> <Layout title="Forge Button Component Examples" path={path}>
<ButtonExamplesContent /> <ButtonExamplesContent />
</Layout> </Layout>
) )
export const ProfileExamplesPage = () => ( export const ProfileExamplesPage = ({ path }: any) => (
<Layout title="Forge Profile Examples"> <Layout title="Forge Profile Examples" path={path}>
<ProfileExamplesContent /> <ProfileExamplesContent />
</Layout> </Layout>
) )
export const NavigationExamplesPage = () => ( export const NavigationExamplesPage = ({ path }: any) => (
<Layout title="Forge Navigation Examples"> <Layout title="Forge Navigation Examples" path={path}>
<NavigationExamplesContent /> <NavigationExamplesContent />
</Layout> </Layout>
) )

View File

@ -7,13 +7,13 @@ const app = new Hono()
app.get('/', c => c.html(<LandingPage />)) app.get('/', c => c.html(<LandingPage />))
app.get('/ssr', c => c.html(<IndexPage />)) app.get('/ssr', c => c.html(<IndexPage path="/ssr" />))
app.get('/ssr/profile', c => c.html(<ProfileExamplesPage />)) app.get('/ssr/profile', c => c.html(<ProfileExamplesPage path="/ssr/profile" />))
app.get('/ssr/buttons', c => c.html(<ButtonExamplesPage />)) app.get('/ssr/buttons', c => c.html(<ButtonExamplesPage path="/ssr/buttons" />))
app.get('/ssr/navigation', c => c.html(<NavigationExamplesPage />)) app.get('/ssr/navigation', c => c.html(<NavigationExamplesPage path="/ssr/navigation" />))
app.get('/styles', c => c.text(stylesToCSS(styles))) app.get('/styles', c => c.text(stylesToCSS(styles)))

View File

@ -134,30 +134,29 @@ function stateName(state: string): string {
return state.startsWith(':') ? state : `:${state}` return state.startsWith(':') ? state : `:${state}`
} }
// adds CSS styles for tag definition // Register base styles, selectors, and states for a class
function registerStyles(name: string, def: TagDef) { function registerClassStyles(name: string, className: string, def: TagDef) {
const rootClassName = makeClassName(name) styles[className] ??= makeStyle(def)
styles[rootClassName] ??= makeStyle(def)
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) { for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
selector = selector.replace('&', `.${rootClassName}`) selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
selector = selector.replace('&', `.${className}`)
if (styles[selector]) throw `${selector} already defined!` if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef) styles[selector] = makeStyle(selectorDef)
} }
for (const [state, style] of Object.entries(def.states ?? {})) for (const [state, style] of Object.entries(def.states ?? {}))
styles[`${rootClassName}${stateName(state)}`] = makeStyle(style) 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 ?? {})) { for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
const partClassName = makeClassName(name, partName) const partClassName = makeClassName(name, partName)
styles[partClassName] ??= makeStyle(partDef) registerClassStyles(name, partClassName, partDef)
for (let [selector, selectorDef] of Object.entries(partDef.selectors ?? {})) {
selector = selector.replace('&', `.${partClassName}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) { for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
@ -171,50 +170,22 @@ function registerStyles(name: string, def: TagDef) {
const variantDef = variantConfig as TagDef const variantDef = variantConfig as TagDef
const baseClassName = makeClassName(name) const baseClassName = makeClassName(name)
const className = `${baseClassName}.${variantName}` const className = `${baseClassName}.${variantName}`
styles[className] ??= makeStyle(variantDef) registerClassStyles(name, className, variantDef)
for (let [selector, selectorDef] of Object.entries(variantDef.selectors ?? {})) {
selector = selector.replace('&', `.${className}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const basePartClassName = makeClassName(name, partName) const basePartClassName = makeClassName(name, partName)
const partClassName = `${basePartClassName}.${variantName}` const partClassName = `${basePartClassName}.${variantName}`
styles[partClassName] ??= makeStyle(partDef) registerClassStyles(name, partClassName, partDef)
for (let [selector, selectorDef] of Object.entries(partDef.selectors ?? {})) {
selector = selector.replace('&', `.${partClassName}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
} else { } else {
// Keyed variant - iterate over the keys // Keyed variant - iterate over the keys
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) { for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
const className = makeClassName(name, undefined, variantName, variantKey) const className = makeClassName(name, undefined, variantName, variantKey)
styles[className] ??= makeStyle(variantDef) registerClassStyles(name, className, variantDef)
for (let [selector, selectorDef] of Object.entries(variantDef.selectors ?? {})) {
selector = selector.replace('&', `.${className}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const partClassName = makeClassName(name, partName, variantName, variantKey) const partClassName = makeClassName(name, partName, variantName, variantKey)
styles[partClassName] ??= makeStyle(partDef) registerClassStyles(name, partClassName, partDef)
for (let [selector, selectorDef] of Object.entries(partDef.selectors ?? {})) {
selector = selector.replace('&', `.${partClassName}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
} }
} }