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 { VStack, HStack } from "./stack"
import { CodeExamples } from "./code"
import { cn } from "./cn"
export type AvatarProps = {
src: string
@ -24,7 +25,7 @@ export const Avatar: FC<AvatarProps> = (props) => {
...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 = () => {

View File

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

View File

@ -4,6 +4,7 @@ import { VStack, HStack } from "./stack"
import { Section } from "./section"
import { H2 } from "./text"
import { CodeExamples } from "./code"
import { cn } from "./cn"
export type ButtonProps = JSX.IntrinsicElements["button"] & {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive"
@ -11,7 +12,7 @@ export type ButtonProps = JSX.IntrinsicElements["button"] & {
}
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 = {
display: "inline-flex",
@ -74,7 +75,7 @@ export const Button: FC<ButtonProps> = (props) => {
...(style as JSX.CSSProperties),
}
return <button {...buttonProps} style={combinedStyles} />
return <button {...buttonProps} class={cn("Button", className)} style={combinedStyles} />
}
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 { VStack } from "./stack"
import { CodeExamples } from "./code"
import { cn } from "./cn"
type DividerProps = PropsWithChildren & {
class?: string
@ -31,7 +32,7 @@ export const Divider: FC<DividerProps> = ({ children, class: className, style })
}
return (
<div class={className} style={containerStyle}>
<div class={cn("Divider", className)} style={containerStyle}>
<div style={lineStyle}></div>
{children && (
<>

View File

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

View File

@ -6,6 +6,7 @@ import { VStack } from "./stack"
import { Section } from "./section"
import { H2, Text } from "./text"
import { CodeExamples } from "./code"
import { cn } from "./cn"
export type IconName = keyof typeof icons
@ -46,7 +47,7 @@ export const Icon: FC<IconProps> = (props) => {
.replace(/class="[^"]*"/, "")
.replace(
/<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} />
@ -64,7 +65,7 @@ export const IconLink: FC<IconLinkProps> = (props) => {
}
return (
<a href={href} target={target} class={className} style={linkStyle}>
<a href={href} target={target} class={cn("IconLink", className)} style={linkStyle}>
<Icon {...iconProps} />
</a>
)

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import "hono/jsx"
import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack"
import { CodeExamples } from "./code"
import { cn } from "./cn"
export type SelectOption = {
value: string
@ -19,7 +20,7 @@ export type SelectProps = Omit<JSX.IntrinsicElements["select"], "children"> & {
}
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 (children && !selectProps.id) {
@ -43,7 +44,7 @@ export const Select: FC<SelectProps> = (props) => {
}
const selectElement = (
<select style={selectStyle} {...selectProps}>
<select style={selectStyle} {...selectProps} class={cn("Select", className)}>
{placeholder && (
<option value="" disabled>
{placeholder}
@ -91,7 +92,7 @@ export const Select: FC<SelectProps> = (props) => {
return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
{labelElement}
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}>
<select style={{ ...selectStyle, flex: 1 }} {...selectProps} class={cn("Select", className)}>
{placeholder && (
<option value="" disabled>
{placeholder}
@ -110,7 +111,7 @@ export const Select: FC<SelectProps> = (props) => {
if (labelPosition === "right") {
return (
<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 && (
<option value="" disabled>
{placeholder}

View File

@ -6,6 +6,7 @@ import { Section } from "./section"
import { H2 } from "./text"
import { RedBox, GreenBox, BlueBox } from "./box"
import { CodeExamples } from "./code"
import { cn } from "./cn"
export const VStack: FC<VStackProps> = (props) => {
return (
@ -15,6 +16,9 @@ export const VStack: FC<VStackProps> = (props) => {
crossAxis={props.h}
wrap={props.wrap}
gap={props.gap}
maxWidth={props.maxWidth}
gridSizes={props.rows}
componentName="VStack"
class={props.class}
style={props.style}
>
@ -31,6 +35,9 @@ export const HStack: FC<HStackProps> = (props) => {
crossAxis={props.v}
wrap={props.wrap}
gap={props.gap}
maxWidth={props.maxWidth}
gridSizes={props.cols}
componentName="HStack"
class={props.class}
style={props.style}
>
@ -42,11 +49,37 @@ export const HStack: FC<HStackProps> = (props) => {
const Stack: FC<StackProps> = (props) => {
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 = {
display: "flex",
flexDirection: props.direction === "row" ? "row" : "column",
flexWrap: props.wrap ? "wrap" : "nowrap",
gap: `${gapPx}px`,
maxWidth: props.maxWidth,
}
if (props.mainAxis) {
@ -62,7 +95,7 @@ const Stack: FC<StackProps> = (props) => {
...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 = () => {
@ -78,6 +111,8 @@ export const Test = () => {
'<VStack gap={4} v="center">...</VStack>',
'<HStack>...</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>
</div>
</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>
)
}
@ -162,6 +286,9 @@ type StackProps = {
crossAxis?: string
wrap?: boolean
gap?: TailwindSize
maxWidth?: string
gridSizes?: number[] // cols for row, rows for col
componentName?: string // for data-howl attribute
class?: string
style?: JSX.CSSProperties
children?: any
@ -173,6 +300,7 @@ type CrossAxisOpts = "start" | "center" | "end" | "stretch" | "baseline"
type CommonStackProps = PropsWithChildren & {
wrap?: boolean
gap?: TailwindSize
maxWidth?: string
class?: string
style?: JSX.CSSProperties
}
@ -180,11 +308,13 @@ type CommonStackProps = PropsWithChildren & {
type VStackProps = CommonStackProps & {
v?: MainAxisOpts // main 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 & {
h?: MainAxisOpts // main 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 {

View File

@ -1,6 +1,7 @@
import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { CodeExamples } from "./code"
import { cn } from "./cn"
type TextProps = PropsWithChildren & {
class?: string
@ -8,31 +9,31 @@ type TextProps = PropsWithChildren & {
}
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 }) => (
<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 }) => (
<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 }) => (
<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 }) => (
<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 }) => (
<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 }) => (
<p class={className} style={{ fontSize: "12px", ...style }}>{children}</p>
<p class={cn("SmallText", className)} style={{ fontSize: "12px", ...style }}>{children}</p>
)
export const Test = () => {