class names

This commit is contained in:
Chris Wanstrath 2025-11-30 09:59:38 -08:00
parent 31f0a2a63a
commit 7f96770d77
12 changed files with 177 additions and 26 deletions

View File

@ -4,6 +4,7 @@ import "hono/jsx"
import type { FC, JSX } from "hono/jsx" import type { FC, JSX } from "hono/jsx"
import { VStack, HStack } from "./stack" import { VStack, HStack } from "./stack"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type AvatarProps = { export type AvatarProps = {
src: string src: string
@ -24,7 +25,7 @@ export const Avatar: FC<AvatarProps> = (props) => {
...style, ...style,
} }
return <img src={src} alt={alt} class={className} style={avatarStyle} /> return <img src={src} alt={alt} class={cn("Avatar", className)} style={avatarStyle} />
} }
export const Test = () => { export const Test = () => {

View File

@ -1,6 +1,7 @@
import "hono/jsx" import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx" import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
type BoxProps = PropsWithChildren & { type BoxProps = PropsWithChildren & {
bg?: string bg?: string
@ -18,7 +19,7 @@ export const Box: FC<BoxProps> = ({ children, bg, color, p, class: className, st
...style, ...style,
} }
return <div class={className} style={boxStyle}>{children}</div> return <div class={cn("Box", className)} style={boxStyle}>{children}</div>
} }
// Common demo box colors // Common demo box colors

View File

@ -4,6 +4,7 @@ import { VStack, HStack } from "./stack"
import { Section } from "./section" import { Section } from "./section"
import { H2 } from "./text" import { H2 } from "./text"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type ButtonProps = JSX.IntrinsicElements["button"] & { export type ButtonProps = JSX.IntrinsicElements["button"] & {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive" variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive"
@ -11,7 +12,7 @@ export type ButtonProps = JSX.IntrinsicElements["button"] & {
} }
export const Button: FC<ButtonProps> = (props) => { export const Button: FC<ButtonProps> = (props) => {
const { variant = "primary", size = "md", style, ...buttonProps } = props const { variant = "primary", size = "md", style, class: className, ...buttonProps } = props
const baseStyles: JSX.CSSProperties = { const baseStyles: JSX.CSSProperties = {
display: "inline-flex", display: "inline-flex",
@ -74,7 +75,7 @@ export const Button: FC<ButtonProps> = (props) => {
...(style as JSX.CSSProperties), ...(style as JSX.CSSProperties),
} }
return <button {...buttonProps} style={combinedStyles} /> return <button {...buttonProps} class={cn("Button", className)} style={combinedStyles} />
} }
export const Test = () => { export const Test = () => {

11
src/cn.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Utility function to merge class names
* Filters out falsy values and joins remaining classes with spaces
*
* @example
* cn('base-class', isActive && 'active', 'another-class') // => "base-class active another-class"
* cn('foo', false, 'bar', null, undefined) // => "foo bar"
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@ -4,6 +4,7 @@ import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx" import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { VStack } from "./stack" import { VStack } from "./stack"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
type DividerProps = PropsWithChildren & { type DividerProps = PropsWithChildren & {
class?: string class?: string
@ -31,7 +32,7 @@ export const Divider: FC<DividerProps> = ({ children, class: className, style })
} }
return ( return (
<div class={className} style={containerStyle}> <div class={cn("Divider", className)} style={containerStyle}>
<div style={lineStyle}></div> <div style={lineStyle}></div>
{children && ( {children && (
<> <>

View File

@ -6,6 +6,7 @@ import { Button } from "./button"
import { Section } from "./section" import { Section } from "./section"
import { H2, H3 } from "./text" import { H2, H3 } from "./text"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
type GridProps = PropsWithChildren & { type GridProps = PropsWithChildren & {
cols?: GridCols cols?: GridCols
@ -42,7 +43,7 @@ export const Grid: FC<GridProps> = (props) => {
...style, ...style,
} }
return <div class={className} style={combinedStyles}>{children}</div> return <div class={cn("Grid", className)} style={combinedStyles}>{children}</div>
} }
function getColumnsValue(cols: GridCols): string { function getColumnsValue(cols: GridCols): string {

View File

@ -6,6 +6,7 @@ import { VStack } from "./stack"
import { Section } from "./section" import { Section } from "./section"
import { H2, Text } from "./text" import { H2, Text } from "./text"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type IconName = keyof typeof icons export type IconName = keyof typeof icons
@ -46,7 +47,7 @@ export const Icon: FC<IconProps> = (props) => {
.replace(/class="[^"]*"/, "") .replace(/class="[^"]*"/, "")
.replace( .replace(
/<svg([^>]*)>/, /<svg([^>]*)>/,
`<svg$1 style="display: block; flex-shrink: 0; width: ${pixelSize}px; height: ${pixelSize}px;" class="${className || ""}">` `<svg$1 style="display: block; flex-shrink: 0; width: ${pixelSize}px; height: ${pixelSize}px;" class="${cn("Icon", className)}">`
) )
return <div dangerouslySetInnerHTML={{ __html: modifiedSvg }} style={iconStyle} /> return <div dangerouslySetInnerHTML={{ __html: modifiedSvg }} style={iconStyle} />
@ -64,7 +65,7 @@ export const IconLink: FC<IconLinkProps> = (props) => {
} }
return ( return (
<a href={href} target={target} class={className} style={linkStyle}> <a href={href} target={target} class={cn("IconLink", className)} style={linkStyle}>
<Icon {...iconProps} /> <Icon {...iconProps} />
</a> </a>
) )

View File

@ -5,6 +5,7 @@ import type { FC, JSX } from "hono/jsx"
import { VStack, HStack } from "./stack" import { VStack, HStack } from "./stack"
import { Grid } from "./grid" import { Grid } from "./grid"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type ImageProps = { export type ImageProps = {
src: string src: string
@ -24,7 +25,7 @@ export const Image: FC<ImageProps> = ({ src, alt = "", width, height, objectFit,
...style, ...style,
} }
return <img src={src} alt={alt} class={className} style={imageStyle} /> return <img src={src} alt={alt} class={cn("Image", className)} style={imageStyle} />
} }
export const Test = () => { export const Test = () => {

View File

@ -4,6 +4,7 @@ import "hono/jsx"
import type { JSX, FC } from "hono/jsx" import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack" import { VStack, HStack } from "./stack"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type InputProps = JSX.IntrinsicElements["input"] & { export type InputProps = JSX.IntrinsicElements["input"] & {
labelPosition?: "above" | "left" | "right" labelPosition?: "above" | "left" | "right"
@ -11,7 +12,7 @@ export type InputProps = JSX.IntrinsicElements["input"] & {
} }
export const Input: FC<InputProps> = (props) => { export const Input: FC<InputProps> = (props) => {
const { labelPosition = "above", children, style, ...inputProps } = props const { labelPosition = "above", children, style, class: className, ...inputProps } = props
const inputStyle: JSX.CSSProperties = { const inputStyle: JSX.CSSProperties = {
height: "40px", height: "40px",
@ -25,7 +26,7 @@ export const Input: FC<InputProps> = (props) => {
} }
if (!children) { if (!children) {
return <input style={inputStyle} {...inputProps} /> return <input style={inputStyle} {...inputProps} class={cn("Input", className)} />
} }
const labelStyle: JSX.CSSProperties = { const labelStyle: JSX.CSSProperties = {
@ -44,7 +45,7 @@ export const Input: FC<InputProps> = (props) => {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
{labelElement} {labelElement}
<input style={inputStyle} {...inputProps} /> <input style={inputStyle} {...inputProps} class={cn("Input", className)} />
</div> </div>
) )
} }
@ -53,7 +54,7 @@ export const Input: FC<InputProps> = (props) => {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
{labelElement} {labelElement}
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} /> <input style={{ ...inputStyle, flex: 1 }} {...inputProps} class={cn("Input", className)} />
</div> </div>
) )
} }
@ -61,7 +62,7 @@ export const Input: FC<InputProps> = (props) => {
if (labelPosition === "right") { if (labelPosition === "right") {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} /> <input style={{ ...inputStyle, flex: 1 }} {...inputProps} class={cn("Input", className)} />
{labelElement} {labelElement}
</div> </div>
) )

View File

@ -4,6 +4,7 @@ import "hono/jsx"
import type { JSX, FC } from "hono/jsx" import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack" import { VStack, HStack } from "./stack"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export type SelectOption = { export type SelectOption = {
value: string value: string
@ -19,7 +20,7 @@ export type SelectProps = Omit<JSX.IntrinsicElements["select"], "children"> & {
} }
export const Select: FC<SelectProps> = (props) => { export const Select: FC<SelectProps> = (props) => {
const { options, placeholder, labelPosition = "above", children, style, ...selectProps } = props const { options, placeholder, labelPosition = "above", children, style, class: className, ...selectProps } = props
// If a label is provided but no id, generate a random id so the label can be clicked // If a label is provided but no id, generate a random id so the label can be clicked
if (children && !selectProps.id) { if (children && !selectProps.id) {
@ -43,7 +44,7 @@ export const Select: FC<SelectProps> = (props) => {
} }
const selectElement = ( const selectElement = (
<select style={selectStyle} {...selectProps}> <select style={selectStyle} {...selectProps} class={cn("Select", className)}>
{placeholder && ( {placeholder && (
<option value="" disabled> <option value="" disabled>
{placeholder} {placeholder}
@ -91,7 +92,7 @@ export const Select: FC<SelectProps> = (props) => {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
{labelElement} {labelElement}
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}> <select style={{ ...selectStyle, flex: 1 }} {...selectProps} class={cn("Select", className)}>
{placeholder && ( {placeholder && (
<option value="" disabled> <option value="" disabled>
{placeholder} {placeholder}
@ -110,7 +111,7 @@ export const Select: FC<SelectProps> = (props) => {
if (labelPosition === "right") { if (labelPosition === "right") {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}> <select style={{ ...selectStyle, flex: 1 }} {...selectProps} class={cn("Select", className)}>
{placeholder && ( {placeholder && (
<option value="" disabled> <option value="" disabled>
{placeholder} {placeholder}

View File

@ -6,6 +6,7 @@ import { Section } from "./section"
import { H2 } from "./text" import { H2 } from "./text"
import { RedBox, GreenBox, BlueBox } from "./box" import { RedBox, GreenBox, BlueBox } from "./box"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
export const VStack: FC<VStackProps> = (props) => { export const VStack: FC<VStackProps> = (props) => {
return ( return (
@ -15,6 +16,9 @@ export const VStack: FC<VStackProps> = (props) => {
crossAxis={props.h} crossAxis={props.h}
wrap={props.wrap} wrap={props.wrap}
gap={props.gap} gap={props.gap}
maxWidth={props.maxWidth}
gridSizes={props.rows}
componentName="VStack"
class={props.class} class={props.class}
style={props.style} style={props.style}
> >
@ -31,6 +35,9 @@ export const HStack: FC<HStackProps> = (props) => {
crossAxis={props.v} crossAxis={props.v}
wrap={props.wrap} wrap={props.wrap}
gap={props.gap} gap={props.gap}
maxWidth={props.maxWidth}
gridSizes={props.cols}
componentName="HStack"
class={props.class} class={props.class}
style={props.style} style={props.style}
> >
@ -42,11 +49,37 @@ export const HStack: FC<HStackProps> = (props) => {
const Stack: FC<StackProps> = (props) => { const Stack: FC<StackProps> = (props) => {
const gapPx = props.gap ? props.gap * 4 : 0 const gapPx = props.gap ? props.gap * 4 : 0
// Use CSS Grid when gridSizes (cols/rows) is provided
if (props.gridSizes) {
const gridTemplate = props.gridSizes.map(size => `${size}fr`).join(" ")
const gridStyles: JSX.CSSProperties = {
display: "grid",
gap: `${gapPx}px`,
maxWidth: props.maxWidth,
}
if (props.direction === "row") {
gridStyles.gridTemplateColumns = gridTemplate
} else {
gridStyles.gridTemplateRows = gridTemplate
}
const combinedStyles = {
...gridStyles,
...props.style,
}
return <div class={cn(props.componentName, props.class)} style={combinedStyles}>{props.children}</div>
}
// Default flexbox behavior
const baseStyles: JSX.CSSProperties = { const baseStyles: JSX.CSSProperties = {
display: "flex", display: "flex",
flexDirection: props.direction === "row" ? "row" : "column", flexDirection: props.direction === "row" ? "row" : "column",
flexWrap: props.wrap ? "wrap" : "nowrap", flexWrap: props.wrap ? "wrap" : "nowrap",
gap: `${gapPx}px`, gap: `${gapPx}px`,
maxWidth: props.maxWidth,
} }
if (props.mainAxis) { if (props.mainAxis) {
@ -62,7 +95,7 @@ const Stack: FC<StackProps> = (props) => {
...props.style, ...props.style,
} }
return <div class={props.class} style={combinedStyles}>{props.children}</div> return <div class={cn(props.componentName, props.class)} style={combinedStyles}>{props.children}</div>
} }
export const Test = () => { export const Test = () => {
@ -78,6 +111,8 @@ export const Test = () => {
'<VStack gap={4} v="center">...</VStack>', '<VStack gap={4} v="center">...</VStack>',
'<HStack>...</HStack>', '<HStack>...</HStack>',
'<HStack gap={6} h="between" v="center">...</HStack>', '<HStack gap={6} h="between" v="center">...</HStack>',
'<HStack cols={[7, 3]} maxWidth="1200px" gap={4}>...</HStack>',
'<VStack rows={[2, 1]} maxWidth="800px">...</VStack>',
]} ]}
/> />
@ -150,6 +185,95 @@ export const Test = () => {
</Grid> </Grid>
</div> </div>
</VStack> </VStack>
{/* Custom column sizing */}
<VStack gap={4}>
<H2>HStack with Custom Column Sizing</H2>
<VStack gap={4}>
<div>
<div style={{ fontSize: "14px", fontWeight: "500", marginBottom: "8px" }}>
cols=[7, 3] (70%/30% split)
</div>
<HStack cols={[7, 3]} gap={4} style={{ backgroundColor: "#f3f4f6", padding: "16px" }}>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>
70% width
</div>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>
30% width
</div>
</HStack>
</div>
<div>
<div style={{ fontSize: "14px", fontWeight: "500", marginBottom: "8px" }}>
cols=[2, 1] (66%/33% split)
</div>
<HStack cols={[2, 1]} gap={4} style={{ backgroundColor: "#f3f4f6", padding: "16px" }}>
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>
2/3 width
</div>
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>
1/3 width
</div>
</HStack>
</div>
<div>
<div style={{ fontSize: "14px", fontWeight: "500", marginBottom: "8px" }}>
cols=[1, 2, 1] (25%/50%/25% split)
</div>
<HStack cols={[1, 2, 1]} gap={4} style={{ backgroundColor: "#f3f4f6", padding: "16px" }}>
<div style={{ backgroundColor: "#e9d5ff", padding: "16px", textAlign: "center" }}>
25%
</div>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>
50%
</div>
<div style={{ backgroundColor: "#fbcfe8", padding: "16px", textAlign: "center" }}>
25%
</div>
</HStack>
</div>
<div>
<div style={{ fontSize: "14px", fontWeight: "500", marginBottom: "8px" }}>
With maxWidth="600px"
</div>
<HStack cols={[7, 3]} maxWidth="600px" gap={4} style={{ backgroundColor: "#f3f4f6", padding: "16px" }}>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>
70% of max 600px
</div>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>
30% of max 600px
</div>
</HStack>
</div>
</VStack>
</VStack>
{/* Custom row sizing */}
<VStack gap={4}>
<H2>VStack with Custom Row Sizing</H2>
<VStack gap={4}>
<div>
<div style={{ fontSize: "14px", fontWeight: "500", marginBottom: "8px" }}>
rows=[2, 1] (2/3 and 1/3 height)
</div>
<VStack
rows={[2, 1]}
gap={4}
style={{ backgroundColor: "#f3f4f6", padding: "16px", height: "300px" }}
>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>
2/3 height
</div>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>
1/3 height
</div>
</VStack>
</div>
</VStack>
</VStack>
</Section> </Section>
) )
} }
@ -162,6 +286,9 @@ type StackProps = {
crossAxis?: string crossAxis?: string
wrap?: boolean wrap?: boolean
gap?: TailwindSize gap?: TailwindSize
maxWidth?: string
gridSizes?: number[] // cols for row, rows for col
componentName?: string // for data-howl attribute
class?: string class?: string
style?: JSX.CSSProperties style?: JSX.CSSProperties
children?: any children?: any
@ -173,6 +300,7 @@ type CrossAxisOpts = "start" | "center" | "end" | "stretch" | "baseline"
type CommonStackProps = PropsWithChildren & { type CommonStackProps = PropsWithChildren & {
wrap?: boolean wrap?: boolean
gap?: TailwindSize gap?: TailwindSize
maxWidth?: string
class?: string class?: string
style?: JSX.CSSProperties style?: JSX.CSSProperties
} }
@ -180,11 +308,13 @@ type CommonStackProps = PropsWithChildren & {
type VStackProps = CommonStackProps & { type VStackProps = CommonStackProps & {
v?: MainAxisOpts // main axis for vertical stack v?: MainAxisOpts // main axis for vertical stack
h?: CrossAxisOpts // cross axis for vertical stack h?: CrossAxisOpts // cross axis for vertical stack
rows?: number[] // custom row sizing (e.g., [7, 3] for 70%/30%)
} }
type HStackProps = CommonStackProps & { type HStackProps = CommonStackProps & {
h?: MainAxisOpts // main axis for horizontal stack h?: MainAxisOpts // main axis for horizontal stack
v?: CrossAxisOpts // cross axis for horizontal stack v?: CrossAxisOpts // cross axis for horizontal stack
cols?: number[] // custom column sizing (e.g., [7, 3] for 70%/30%)
} }
function getJustifyContent(axis: string): string { function getJustifyContent(axis: string): string {

View File

@ -1,6 +1,7 @@
import "hono/jsx" import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx" import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { CodeExamples } from "./code" import { CodeExamples } from "./code"
import { cn } from "./cn"
type TextProps = PropsWithChildren & { type TextProps = PropsWithChildren & {
class?: string class?: string
@ -8,31 +9,31 @@ type TextProps = PropsWithChildren & {
} }
export const H1: FC<TextProps> = ({ children, class: className, style }) => ( export const H1: FC<TextProps> = ({ children, class: className, style }) => (
<h1 class={className} style={{ fontSize: "24px", fontWeight: "bold", ...style }}>{children}</h1> <h1 class={cn("H1", className)} style={{ fontSize: "24px", fontWeight: "bold", ...style }}>{children}</h1>
) )
export const H2: FC<TextProps> = ({ children, class: className, style }) => ( export const H2: FC<TextProps> = ({ children, class: className, style }) => (
<h2 class={className} style={{ fontSize: "20px", fontWeight: "bold", ...style }}>{children}</h2> <h2 class={cn("H2", className)} style={{ fontSize: "20px", fontWeight: "bold", ...style }}>{children}</h2>
) )
export const H3: FC<TextProps> = ({ children, class: className, style }) => ( export const H3: FC<TextProps> = ({ children, class: className, style }) => (
<h3 class={className} style={{ fontSize: "18px", fontWeight: "600", ...style }}>{children}</h3> <h3 class={cn("H3", className)} style={{ fontSize: "18px", fontWeight: "600", ...style }}>{children}</h3>
) )
export const H4: FC<TextProps> = ({ children, class: className, style }) => ( export const H4: FC<TextProps> = ({ children, class: className, style }) => (
<h4 class={className} style={{ fontSize: "16px", fontWeight: "600", ...style }}>{children}</h4> <h4 class={cn("H4", className)} style={{ fontSize: "16px", fontWeight: "600", ...style }}>{children}</h4>
) )
export const H5: FC<TextProps> = ({ children, class: className, style }) => ( export const H5: FC<TextProps> = ({ children, class: className, style }) => (
<h5 class={className} style={{ fontSize: "14px", fontWeight: "500", ...style }}>{children}</h5> <h5 class={cn("H5", className)} style={{ fontSize: "14px", fontWeight: "500", ...style }}>{children}</h5>
) )
export const Text: FC<TextProps> = ({ children, class: className, style }) => ( export const Text: FC<TextProps> = ({ children, class: className, style }) => (
<p class={className} style={{ fontSize: "14px", ...style }}>{children}</p> <p class={cn("Text", className)} style={{ fontSize: "14px", ...style }}>{children}</p>
) )
export const SmallText: FC<TextProps> = ({ children, class: className, style }) => ( export const SmallText: FC<TextProps> = ({ children, class: className, style }) => (
<p class={className} style={{ fontSize: "12px", ...style }}>{children}</p> <p class={cn("SmallText", className)} style={{ fontSize: "12px", ...style }}>{children}</p>
) )
export const Test = () => { export const Test = () => {