From 67fae70c83fe6d94b3146d35e6350f9e668c506b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 29 Nov 2025 13:50:17 -0800 Subject: [PATCH] howl --- .gitignore | 34 ++++++ CLAUDE.md | 111 +++++++++++++++++++ README.md | 8 ++ bun.lock | 33 ++++++ package.json | 18 +++ src/avatar.tsx | 117 ++++++++++++++++++++ src/button.tsx | 134 ++++++++++++++++++++++ src/divider.tsx | 61 ++++++++++ src/grid.tsx | 158 ++++++++++++++++++++++++++ src/icon.tsx | 210 +++++++++++++++++++++++++++++++++++ src/image.tsx | 219 ++++++++++++++++++++++++++++++++++++ src/index.tsx | 28 +++++ src/input.tsx | 181 ++++++++++++++++++++++++++++++ src/placeholder.tsx | 245 +++++++++++++++++++++++++++++++++++++++++ src/select.tsx | 263 ++++++++++++++++++++++++++++++++++++++++++++ src/stack.tsx | 193 ++++++++++++++++++++++++++++++++ src/types.ts | 1 + test/server.tsx | 37 +++++++ tsconfig.json | 36 ++++++ 19 files changed, 2087 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/avatar.tsx create mode 100644 src/button.tsx create mode 100644 src/divider.tsx create mode 100644 src/grid.tsx create mode 100644 src/icon.tsx create mode 100644 src/image.tsx create mode 100644 src/index.tsx create mode 100644 src/input.tsx create mode 100644 src/placeholder.tsx create mode 100644 src/select.tsx create mode 100644 src/stack.tsx create mode 100644 src/types.ts create mode 100644 test/server.tsx create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2330f4 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 🐺 howl + +Howl is a fork of `werewolf-ui`, without any Tailwind. + +```bash +bun install +bun dev +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9bf8c8f --- /dev/null +++ b/bun.lock @@ -0,0 +1,33 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "howl", + "dependencies": { + "hono": "^4.10.7", + "lucide-static": "^0.555.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], + + "lucide-static": ["lucide-static@0.555.0", "", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="], + + "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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0681fc6 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "howl", + "module": "index.tsx", + "type": "module", + "scripts": { + "dev": "bun run --hot test/server.tsx" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.10.7", + "lucide-static": "^0.555.0" + } +} \ No newline at end of file diff --git a/src/avatar.tsx b/src/avatar.tsx new file mode 100644 index 0000000..f02ea6f --- /dev/null +++ b/src/avatar.tsx @@ -0,0 +1,117 @@ +import "hono/jsx" +import type { FC, JSX } from "hono/jsx" +import { VStack, HStack } from "./stack" + +export type AvatarProps = { + src: string + alt?: string + size?: number + rounded?: boolean + style?: JSX.CSSProperties +} + +export const Avatar: FC = (props) => { + const { src, size = 32, rounded, style, alt = "" } = props + + const avatarStyle: JSX.CSSProperties = { + width: `${size}px`, + height: `${size}px`, + borderRadius: rounded ? "9999px" : undefined, + ...style, + } + + return {alt} +} + +export const Test = () => { + const sampleImages = [ + "https://picsum.photos/seed/3/200/200", + "https://picsum.photos/seed/2/200/200", + "https://picsum.photos/seed/8/200/200", + "https://picsum.photos/seed/9/200/200", + ] + + return ( + + {/* Size variations */} + +

Size Variations

+ + {[24, 32, 48, 64, 96].map((size) => ( + + +

+ {size}x{size} +

+
+ ))} +
+
+ + {/* Rounded vs Square */} + +

Rounded vs Square

+ + + +

Square

+
+ + +

Rounded

+
+
+
+ + {/* Different images */} + +

Different Images

+ + {sampleImages.map((src, index) => ( + + +

Image {index + 1}

+
+ ))} +
+
+ + {/* Custom styles */} + +

With Custom Styles

+ + + +

With Border

+
+ + +

With Shadow

+
+ + +

Border + Shadow

+
+
+
+
+ ) +} diff --git a/src/button.tsx b/src/button.tsx new file mode 100644 index 0000000..7f5efbb --- /dev/null +++ b/src/button.tsx @@ -0,0 +1,134 @@ +import "hono/jsx" +import type { JSX, FC } from "hono/jsx" +import { VStack, HStack } from "./stack" + +export type ButtonProps = JSX.IntrinsicElements["button"] & { + variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive" + size?: "sm" | "md" | "lg" +} + +export const Button: FC = (props) => { + const { variant = "primary", size = "md", style, ...buttonProps } = props + + const baseStyles: JSX.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + fontWeight: "500", + transition: "all 0.2s", + outline: "none", + cursor: "pointer", + borderRadius: "4px", + border: "1px solid transparent", + } + + const variantStyles: Record = { + primary: { + backgroundColor: "#3b82f6", + color: "#ffffff", + }, + secondary: { + backgroundColor: "#64748b", + color: "#ffffff", + }, + outline: { + backgroundColor: "transparent", + color: "#000000", + borderColor: "#d1d5db", + }, + ghost: { + backgroundColor: "transparent", + color: "#000000", + }, + destructive: { + backgroundColor: "#ef4444", + color: "#ffffff", + }, + } + + const sizeStyles: Record = { + sm: { + height: "32px", + padding: "0 12px", + fontSize: "14px", + }, + md: { + height: "40px", + padding: "0 16px", + fontSize: "14px", + }, + lg: { + height: "48px", + padding: "0 24px", + fontSize: "16px", + }, + } + + const combinedStyles: JSX.CSSProperties = { + ...baseStyles, + ...variantStyles[variant], + ...sizeStyles[size], + ...(style || {}), + } + + return + + + + + + + + {/* Sizes */} + +

Button Sizes

+ + + + + +
+ + {/* With custom content */} + +

Custom Content

+ + + + +
+ + {/* Native attributes work */} + +

Native Attributes

+ + + + + +
+ + ) +} diff --git a/src/divider.tsx b/src/divider.tsx new file mode 100644 index 0000000..decedf4 --- /dev/null +++ b/src/divider.tsx @@ -0,0 +1,61 @@ +import "hono/jsx" +import type { FC, PropsWithChildren, JSX } from "hono/jsx" +import { VStack } from "./stack" + +type DividerProps = PropsWithChildren & { + style?: JSX.CSSProperties +} + +export const Divider: FC = ({ children, style }) => { + const containerStyle: JSX.CSSProperties = { + display: "flex", + alignItems: "center", + margin: "16px 0", + ...style, + } + + const lineStyle: JSX.CSSProperties = { + flex: 1, + borderTop: "1px solid #d1d5db", + } + + const textStyle: JSX.CSSProperties = { + padding: "0 12px", + fontSize: "14px", + color: "#6b7280", + backgroundColor: "white", + } + + return ( +
+
+ {children && ( + <> + {children} +
+ + )} +
+ ) +} + +export const Test = () => { + return ( + +

Divider Examples

+ + +

Would you like to continue

+ OR WOULD YOU LIKE TO +

Submit to certain death

+
+ + {/* Just a line */} + +

Look a line 👇

+ +

So cool, so straight!

+
+
+ ) +} diff --git a/src/grid.tsx b/src/grid.tsx new file mode 100644 index 0000000..5741ee0 --- /dev/null +++ b/src/grid.tsx @@ -0,0 +1,158 @@ +import type { TailwindSize } from "./types" +import "hono/jsx" +import type { FC, PropsWithChildren, JSX } from "hono/jsx" +import { VStack } from "./stack" +import { Button } from "./button" + +type GridProps = PropsWithChildren & { + cols?: GridCols + gap?: TailwindSize + v?: keyof typeof alignItemsMap + h?: keyof typeof justifyItemsMap + style?: JSX.CSSProperties +} + +type GridCols = number | { sm?: number; md?: number; lg?: number; xl?: number } + +export const Grid: FC = (props) => { + const { cols = 2, gap = 4, v, h, style, children } = props + + const gapPx = gap * 4 + + const baseStyles: JSX.CSSProperties = { + display: "grid", + gridTemplateColumns: getColumnsValue(cols), + gap: `${gapPx}px`, + } + + if (v) { + baseStyles.alignItems = alignItemsMap[v] + } + + if (h) { + baseStyles.justifyItems = justifyItemsMap[h] + } + + const combinedStyles = { + ...baseStyles, + ...style, + } + + return
{children}
+} + +function getColumnsValue(cols: GridCols): string { + if (typeof cols === "number") { + return `repeat(${cols}, minmax(0, 1fr))` + } + + // For responsive grids, we'll use the largest value + // In a real implementation, you'd want media queries which require CSS + // For now, let's use the largest value specified + const largestCols = cols.xl || cols.lg || cols.md || cols.sm || 1 + return `repeat(${largestCols}, minmax(0, 1fr))` +} + +const alignItemsMap = { + start: "start", + center: "center", + end: "end", + stretch: "stretch", +} as const + +const justifyItemsMap = { + start: "start", + center: "center", + end: "end", + stretch: "stretch", +} as const + +export const Test = () => { + return ( + + +

Grid Examples

+ + {/* Simple 3-column grid */} + +

Simple 3 columns: cols=3

+ +
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
Item 6
+
+
+ + {/* Responsive grid */} + +

+ Responsive: cols={sm: 1, md: 2, lg: 3} +

+ +
Card 1
+
Card 2
+
Card 3
+
Card 4
+
+
+ + {/* More responsive examples */} + +

+ More responsive: cols={sm: 2, lg: 4, xl: 6} +

+ +
Item A
+
Item B
+
Item C
+
Item D
+
Item E
+
Item F
+
+
+ + {/* Payment method example */} + +

Payment buttons example

+ + + + + +
+ + {/* Alignment examples */} + +

+ Alignment: v="center" h="center" +

+ +
Item 1
+
Item 2
+
Item 3
+
+
+ + +

Alignment: v="start" h="end"

+ +
Left
+
Right
+
+
+
+
+ ) +} diff --git a/src/icon.tsx b/src/icon.tsx new file mode 100644 index 0000000..b485976 --- /dev/null +++ b/src/icon.tsx @@ -0,0 +1,210 @@ +import "hono/jsx" +import type { FC, JSX } from "hono/jsx" +import * as icons from "lucide-static" +import { Grid } from "./grid" +import { VStack, HStack } from "./stack" + +export type IconName = keyof typeof icons + +type IconProps = { + name: IconName + size?: number + class?: string + style?: JSX.CSSProperties +} + +type IconLinkProps = IconProps & { + href?: string + target?: string +} + +export const Icon: FC = (props) => { + const { name, size = 6, class: className, style } = props + + const iconSvg = icons[name] + + if (!iconSvg) { + throw new Error(`Icon "${name}" not found in Lucide icons`) + } + + const pixelSize = sizeToPixels(size) + const iconStyle: JSX.CSSProperties = { + display: "block", + flexShrink: "0", + width: `${pixelSize}px`, + height: `${pixelSize}px`, + ...style, + } + + // Modify the SVG string to include our custom attributes + const modifiedSvg = iconSvg + .replace(/width="[^"]*"/, "") + .replace(/height="[^"]*"/, "") + .replace(/class="[^"]*"/, "") + .replace( + /]*)>/, + `` + ) + + return
+} + +export const IconLink: FC = (props) => { + const { href = "#", target, class: className, style, ...iconProps } = props + + const linkStyle: JSX.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + transition: "opacity 0.2s", + ...style, + } + + return ( + + + + ) +} + +export const Test = () => { + return ( +
+ {/* === ICON TESTS === */} + + {/* Size variations */} +
+

Icon Size Variations

+
+ {([3, 4, 5, 6, 8, 10, 12, 16] as const).map((size) => ( +
+ +

{size}

+
+ ))} +
+
+ + {/* Styling with CSS classes */} +
+

Styling with CSS Classes

+ + + +

Default

+
+ + +

Blue Color

+
+ + +

Thin Stroke

+
+ + +

Filled

+
+ + +

Hover Effect

+
+
+
+ + {/* Advanced styling */} +
+

Advanced Styling

+ + + +

Filled Heart

+
+ + +

Thick Stroke

+
+ + +

Sun Icon

+
+ + +

Drop Shadow

+
+
+
+ + {/* === ICON LINK TESTS === */} + + {/* Basic icon links */} +
+

Icon Links

+
+
+ +

Home Link

+
+
+ +

External Link

+
+
+ +

Email Link

+
+
+ +

Phone Link

+
+
+
+ + {/* Styled icon links */} +
+

Styled Icon Links

+ + + +

Button Style

+
+ + +

Circle Border

+
+ + +

Red Heart

+
+ + +

Filled Star

+
+
+
+
+ ) +} + +function sizeToPixels(size: number): number { + return size * 4 +} diff --git a/src/image.tsx b/src/image.tsx new file mode 100644 index 0000000..f4aec23 --- /dev/null +++ b/src/image.tsx @@ -0,0 +1,219 @@ +import "hono/jsx" +import type { FC, JSX } from "hono/jsx" +import { VStack, HStack } from "./stack" +import { Grid } from "./grid" + +export type ImageProps = { + src: string + alt?: string + width?: number + height?: number + objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down" + style?: JSX.CSSProperties +} + +export const Image: FC = ({ src, alt = "", width, height, objectFit, style }) => { + const imageStyle: JSX.CSSProperties = { + width: width ? `${width}px` : undefined, + height: height ? `${height}px` : undefined, + objectFit: objectFit, + ...style, + } + + return {alt} +} + +export const Test = () => { + const sampleImages = [ + "https://picsum.photos/seed/1/400/600", // Portrait + "https://picsum.photos/seed/2/600/400", // Landscape + "https://picsum.photos/seed/3/300/300", // Square + "https://picsum.photos/seed/4/200/100", // Small image + ] + + return ( + +

Image Examples

+ + {/* Size variations */} + +

Size Variations

+ + + 64x64 +

64x64

+
+ + 96x96 +

96x96

+
+ + 128x128 +

128x128

+
+ + 192x128 +

192x128

+
+
+
+ + {/* Object fit variations */} + +

Object Fit Variations

+

+ Same image with different object-fit values +

+ + + Object cover +

object-fit: cover

+
+ + Object contain +

object-fit: contain

+
+ + Object fill +

object-fit: fill

+
+ + Object scale-down +

object-fit: scale-down

+
+ + Object none +

object-fit: none

+
+
+
+ + {/* Styling examples */} + +

Styling Examples

+ + + Rounded with border +

Rounded + Border

+
+ + With shadow +

With Shadow

+
+ + Circular with effects +

Circular + Effects

+
+
+
+ + {/* Common use cases */} + +

Common Use Cases

+ + {/* Avatar */} + +

Avatar

+ Avatar +
+ + {/* Card image */} + +

Card Image

+ + Card image + +
Card Title
+

Card description goes here

+
+
+
+ + {/* Gallery grid */} + +

Gallery Grid

+ + {sampleImages.map((src, i) => ( + {`Gallery + ))} + +
+
+
+ ) +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..b2acd23 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,28 @@ +export { Button } from "./button" +export type { ButtonProps } from "./button" + +export { Icon, IconLink } from "./icon" +export type { IconName } from "./icon" + +export { VStack, HStack } from "./stack" + +export { Grid } from "./grid" + +export { Divider } from "./divider" + +export { Avatar } from "./avatar" +export type { AvatarProps } from "./avatar" + +export { Image } from "./image" +export type { ImageProps } from "./image" + +export { Input } from "./input" +export type { InputProps } from "./input" + +export { Select } from "./select" +export type { SelectProps, SelectOption } from "./select" + +export { Placeholder } from "./placeholder" +export { default as PlaceholderDefault } from "./placeholder" + +export type { TailwindSize } from "./types" diff --git a/src/input.tsx b/src/input.tsx new file mode 100644 index 0000000..15c5713 --- /dev/null +++ b/src/input.tsx @@ -0,0 +1,181 @@ +import "hono/jsx" +import type { JSX, FC } from "hono/jsx" +import { VStack, HStack } from "./stack" + +export type InputProps = JSX.IntrinsicElements["input"] & { + labelPosition?: "above" | "left" | "right" + children?: any +} + +export const Input: FC = (props) => { + const { labelPosition = "above", children, style, ...inputProps } = props + + const inputStyle: JSX.CSSProperties = { + height: "40px", + padding: "8px 12px", + borderRadius: "6px", + border: "1px solid #d1d5db", + backgroundColor: "white", + fontSize: "14px", + outline: "none", + ...style, + } + + if (!children) { + return + } + + const labelStyle: JSX.CSSProperties = { + fontSize: "14px", + fontWeight: "500", + color: "#111827", + } + + const labelElement = ( + + ) + + if (labelPosition === "above") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "left") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "right") { + return ( +
+ + {labelElement} +
+ ) + } + + return null +} + +export const Test = () => { + return ( + + {/* Basic inputs */} + +

Basic Inputs

+ + + + + +
+ + {/* Custom styling */} + +

Custom Styling

+ + + + + +
+ + {/* With values */} + +

With Values

+ + + + +
+ + {/* Disabled state */} + +

Disabled State

+ + + + +
+ + {/* Label above */} + +

Label Above

+ + Name + + Email + + + Password + + +
+ + {/* Label to the left */} + +

Label Left

+ + + Name + + + Email + + + Password + + +
+ + {/* Label to the right */} + +

Label Right

+ + + Name + + + Email + + + Password + + +
+ + {/* Horizontal layout */} + +

Horizontal Layout

+ + First + Last + Age + +
+ + {/* Custom styling */} + +

Custom Input Styling

+ + + Custom Label + + + Required Field + + +
+
+ ) +} diff --git a/src/placeholder.tsx b/src/placeholder.tsx new file mode 100644 index 0000000..0c4332b --- /dev/null +++ b/src/placeholder.tsx @@ -0,0 +1,245 @@ +import "hono/jsx" +import { Avatar } from "./avatar" +import type { AvatarProps } from "./avatar" +import { Image } from "./image" +import type { ImageProps } from "./image" +import { VStack, HStack } from "./stack" +import { Grid } from "./grid" + +export const Placeholder = { + Avatar(props: PlaceholderAvatarProps) { + const { size = 32, seed = "seed", type = "dylan", transparent, alt, style, rounded } = props + + // Generate DiceBear avatar URL + const url = new URL(`https://api.dicebear.com/9.x/${type}/svg`) + url.searchParams.set("seed", seed) + url.searchParams.set("size", size.toString()) + + if (transparent) { + url.searchParams.set("backgroundColor", "transparent") + } + + return + }, + + Image(props: PlaceholderImageProps) { + const { width = 200, height = 200, seed = 1, alt = "Placeholder image", objectFit, style } = props + + // Generate Picsum Photos URL with seed for consistent images + const src = `https://picsum.photos/${width}/${height}?random=${seed}` + + return {alt} + }, +} + +export const Test = () => { + return ( + + {/* === AVATAR TESTS === */} + + {/* Show all available avatar styles */} + +

+ All Avatar Styles ({allStyles.length} total) +

+ + {allStyles.map((style) => ( + + +

{style}

+
+ ))} +
+
+ + {/* Avatar size variations */} + +

Avatar Size Variations

+ + {[24, 32, 48, 64].map((size) => ( + + +

{size}px

+
+ ))} +
+
+ + {/* Avatar styling combinations */} + +

Avatar Styling Options

+ + + +

Rounded + Background

+
+ +
+ +
+

Rounded + Transparent

+
+ + +

Square + Background

+
+ +
+ +
+

Square + Transparent

+
+
+
+ + {/* Avatar seed variations */} + +

+ Avatar Seeds (Same Style, Different People) +

+ + {["alice", "bob", "charlie", "diana"].map((seed) => ( + + +

"{seed}"

+
+ ))} +
+
+ + {/* === IMAGE TESTS === */} + +

Placeholder Images

+ + {/* Size variations */} + +

Size Variations

+ + {[ + { width: 100, height: 100 }, + { width: 150, height: 100 }, + { width: 200, height: 150 }, + { width: 250, height: 200 }, + ].map(({ width, height }) => ( + + +

+ {width}×{height} +

+
+ ))} +
+
+ + {/* Different seeds - show variety */} + +

+ Different Images (Different Seeds) +

+ + {[1, 2, 3, 4, 5].map((seed) => ( + + +

Seed {seed}

+
+ ))} +
+
+ + {/* With custom styles */} + +

With Custom Styles

+ + + +

Rounded + Border

+
+ + +

With Shadow

+
+ + +

Circular + Effects

+
+
+
+
+
+ ) +} + +// Type definitions +type PlaceholderAvatarProps = Omit & { + seed?: string + type?: DicebearStyleName + transparent?: boolean +} + +type PlaceholderImageProps = Omit & { + width?: number + height?: number + seed?: number + alt?: string +} + +// All supported DiceBear HTTP styleNames. Source: https://www.dicebear.com/styles +const allStyles = [ + "adventurer", + "adventurer-neutral", + "avataaars", + "avataaars-neutral", + "big-ears", + "big-ears-neutral", + "big-smile", + "bottts", + "bottts-neutral", + "croodles", + "croodles-neutral", + "dylan", + "fun-emoji", + "glass", + "icons", + "identicon", + "initials", + "lorelei", + "lorelei-neutral", + "micah", + "miniavs", + "notionists", + "notionists-neutral", + "open-peeps", + "personas", + "pixel-art", + "pixel-art-neutral", + "rings", + "shapes", + "thumbs", +] as const + +type DicebearStyleName = (typeof allStyles)[number] + +// Default export for convenience +export default Placeholder diff --git a/src/select.tsx b/src/select.tsx new file mode 100644 index 0000000..085c634 --- /dev/null +++ b/src/select.tsx @@ -0,0 +1,263 @@ +import "hono/jsx" +import type { JSX, FC } from "hono/jsx" +import { VStack, HStack } from "./stack" + +export type SelectOption = { + value: string + label: string + disabled?: boolean +} + +export type SelectProps = Omit & { + options: SelectOption[] + placeholder?: string + labelPosition?: "above" | "left" | "right" + children?: any +} + +export const Select: FC = (props) => { + const { options, placeholder, labelPosition = "above", children, style, ...selectProps } = props + + // If a label is provided but no id, generate a random id so the label can be clicked + if (children && !selectProps.id) { + selectProps.id = `random-${Math.random().toString(36)}` + } + + const selectStyle: JSX.CSSProperties = { + height: "40px", + padding: "8px 32px 8px 12px", + borderRadius: "6px", + border: "1px solid #d1d5db", + backgroundColor: "white", + fontSize: "14px", + outline: "none", + appearance: "none", + backgroundImage: `url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzZCNzI4MCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+")`, + backgroundRepeat: "no-repeat", + backgroundPosition: "right 8px center", + backgroundSize: "16px 16px", + ...style, + } + + const selectElement = ( + + ) + + if (!children) { + return selectElement + } + + const labelStyle: JSX.CSSProperties = { + fontSize: "14px", + fontWeight: "500", + color: "#111827", + } + + const labelElement = ( + + ) + + if (labelPosition === "above") { + return ( +
+ {labelElement} + {selectElement} +
+ ) + } + + if (labelPosition === "left") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "right") { + return ( +
+ + {labelElement} +
+ ) + } + + return null +} + +export const Test = () => { + const months = [ + { value: "01", label: "January" }, + { value: "02", label: "February" }, + { value: "03", label: "March" }, + { value: "04", label: "April" }, + { value: "05", label: "May" }, + { value: "06", label: "June" }, + { value: "07", label: "July" }, + { value: "08", label: "August" }, + { value: "09", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, + ] + + const years = Array.from({ length: 10 }, (_, i) => ({ + value: String(2024 + i), + label: String(2024 + i), + })) + + const countries = [ + { value: "us", label: "United States" }, + { value: "ca", label: "Canada" }, + { value: "uk", label: "United Kingdom" }, + { value: "de", label: "Germany" }, + { value: "fr", label: "France" }, + { value: "au", label: "Australia", disabled: true }, + ] + + return ( + + {/* Basic selects */} + +

Basic Selects

+ + + + + + + Birth Month + + + + +
+ + {/* Label to the left */} + +

Label Left

+ + + + + +
+ + {/* Label to the right */} + +

Label Right

+ + + + +
+ + {/* Horizontal layout (like card form) */} + +

Horizontal Layout

+ + + + +
+
+ ) +} diff --git a/src/stack.tsx b/src/stack.tsx new file mode 100644 index 0000000..ed3bc53 --- /dev/null +++ b/src/stack.tsx @@ -0,0 +1,193 @@ +import type { TailwindSize } from "./types" +import "hono/jsx" +import type { FC, PropsWithChildren, JSX } from "hono/jsx" +import { Grid } from "./grid" + +export const VStack: FC = (props) => { + return ( + + {props.children} + + ) +} + +export const HStack: FC = (props) => { + return ( + + {props.children} + + ) +} + +const Stack: FC = (props) => { + const gapPx = props.gap ? props.gap * 4 : 0 + + const baseStyles: JSX.CSSProperties = { + display: "flex", + flexDirection: props.direction === "row" ? "row" : "column", + flexWrap: props.wrap ? "wrap" : "nowrap", + gap: `${gapPx}px`, + } + + if (props.mainAxis) { + baseStyles.justifyContent = getJustifyContent(props.mainAxis) + } + + if (props.crossAxis) { + baseStyles.alignItems = getAlignItems(props.crossAxis) + } + + const combinedStyles = { + ...baseStyles, + ...props.style, + } + + return
{props.children}
+} + +export const Test = () => { + const mainAxisOpts: MainAxisOpts[] = ["start", "center", "end", "between", "around", "evenly"] + const crossAxisOpts: CrossAxisOpts[] = ["start", "center", "end", "stretch", "baseline"] + + return ( + + {/* HStack layout matrix */} + +

HStack Layout

+
+ + {/* Header row: blank + h labels */} +
+ {mainAxisOpts.map((h) => ( +
+ h: {h} +
+ ))} + + {/* Each row: v label + HStack cells */} + {crossAxisOpts.map((v) => [ +
+ v: {v} +
, + ...mainAxisOpts.map((h) => ( + +
Aa
+
Aa
+
Aa
+
+ )), + ])} +
+
+
+ + {/* VStack layout matrix */} + +

VStack Layout

+
+ + {/* Header row: blank + h labels */} +
+ {crossAxisOpts.map((h) => ( +
+ h: {h} +
+ ))} + + {/* Each row: v label + VStack cells */} + {mainAxisOpts.map((v) => [ +
+ v: {v} +
, + ...crossAxisOpts.map((h) => ( + +
Aa
+
Aa
+
Aa
+
+ )), + ])} +
+
+
+
+ ) +} + +type StackDirection = "row" | "col" + +type StackProps = { + direction: StackDirection + mainAxis?: string + crossAxis?: string + wrap?: boolean + gap?: TailwindSize + style?: JSX.CSSProperties + children?: any +} + +type MainAxisOpts = "start" | "center" | "end" | "between" | "around" | "evenly" +type CrossAxisOpts = "start" | "center" | "end" | "stretch" | "baseline" + +type CommonStackProps = PropsWithChildren & { + wrap?: boolean + gap?: TailwindSize + style?: JSX.CSSProperties +} + +type VStackProps = CommonStackProps & { + v?: MainAxisOpts // main axis for vertical stack + h?: CrossAxisOpts // cross axis for vertical stack +} + +type HStackProps = CommonStackProps & { + h?: MainAxisOpts // main axis for horizontal stack + v?: CrossAxisOpts // cross axis for horizontal stack +} + +function getJustifyContent(axis: string): string { + const map: Record = { + start: "flex-start", + center: "center", + end: "flex-end", + between: "space-between", + around: "space-around", + evenly: "space-evenly", + } + return map[axis] || "flex-start" +} + +function getAlignItems(axis: string): string { + const map: Record = { + start: "flex-start", + center: "center", + end: "flex-end", + stretch: "stretch", + baseline: "baseline", + } + return map[axis] || "stretch" +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5ad3679 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +export type TailwindSize = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96 diff --git a/test/server.tsx b/test/server.tsx new file mode 100644 index 0000000..5596087 --- /dev/null +++ b/test/server.tsx @@ -0,0 +1,37 @@ +import { Hono } from 'hono' +import { readdirSync } from 'fs' +import { join } from 'path' + +const port = process.env.PORT ?? '3100' +const app = new Hono() + +app.get('/:file', async c => { + const file = c.req.param('file') ?? '' + const fileName = (file).replace('.', '') + const path = join(process.env.PWD ?? '.', `/src/${fileName}.tsx`) + + if (!(await Bun.file(path).exists())) + return c.text('404 Not Found', 404) + + const page = await import(path + `?t=${Date.now()}`) + return c.html(<>

{file}

) +}) + +app.get('/', c => { + return c.html(<> +

Test Files

+
    {testFiles().map(x =>
  • {x}
  • )}
+ ) +}) + +function testFiles(): string[] { + return readdirSync('./test') + .filter(x => x.endsWith('.tsx') && !x.startsWith('server')) + .map(x => x.replace('.tsx', '')) + .sort() +} + +export default { + fetch: app.fetch, + port +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..408e62d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "#*": [ + "src/*" + ] + }, + } +} \ No newline at end of file