howl
This commit is contained in:
commit
67fae70c83
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
|
|
@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
8
README.md
Normal file
8
README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# 🐺 howl
|
||||
|
||||
Howl is a fork of `werewolf-ui`, without any Tailwind.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev
|
||||
```
|
||||
33
bun.lock
Normal file
33
bun.lock
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
117
src/avatar.tsx
Normal file
117
src/avatar.tsx
Normal file
|
|
@ -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<AvatarProps> = (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 <img src={src} alt={alt} style={avatarStyle} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack gap={8} style={{ padding: "24px" }}>
|
||||
{/* Size variations */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Size Variations</h2>
|
||||
<HStack gap={4}>
|
||||
{[24, 32, 48, 64, 96].map((size) => (
|
||||
<VStack key={size} h="center" gap={2}>
|
||||
<Avatar src={sampleImages[0]!} size={size} alt="Sample" />
|
||||
<p style={{ fontSize: "14px" }}>
|
||||
{size}x{size}
|
||||
</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Rounded vs Square */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Rounded vs Square</h2>
|
||||
<HStack gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Avatar src={sampleImages[0]!} size={64} alt="Sample" />
|
||||
<p style={{ fontSize: "14px" }}>Square</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Avatar src={sampleImages[0]!} size={64} rounded alt="Sample" />
|
||||
<p style={{ fontSize: "14px" }}>Rounded</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Different images */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Different Images</h2>
|
||||
<HStack gap={4}>
|
||||
{sampleImages.map((src, index) => (
|
||||
<VStack key={src} h="center" gap={2}>
|
||||
<Avatar src={src} size={64} rounded alt={`Sample ${index + 1}`} />
|
||||
<p style={{ fontSize: "14px" }}>Image {index + 1}</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Custom styles */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Custom Styles</h2>
|
||||
<HStack gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Avatar
|
||||
src={sampleImages[0]!}
|
||||
size={64}
|
||||
rounded
|
||||
style={{ border: "4px solid #3b82f6" }}
|
||||
alt="With border"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>With Border</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Avatar
|
||||
src={sampleImages[1]!}
|
||||
size={64}
|
||||
rounded
|
||||
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
|
||||
alt="With shadow"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>With Shadow</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Avatar
|
||||
src={sampleImages[2]!}
|
||||
size={64}
|
||||
rounded
|
||||
style={{ border: "4px solid #22c55e", boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
|
||||
alt="Border + shadow"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Border + Shadow</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
134
src/button.tsx
Normal file
134
src/button.tsx
Normal file
|
|
@ -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<ButtonProps> = (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<string, JSX.CSSProperties> = {
|
||||
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<string, JSX.CSSProperties> = {
|
||||
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 <button {...buttonProps} style={combinedStyles} />
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
return (
|
||||
<VStack gap={8} style={{ padding: "24px" }}>
|
||||
{/* Variants */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Button Variants</h2>
|
||||
<HStack gap={4}>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Sizes */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Button Sizes</h2>
|
||||
<HStack gap={4} v="end">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md">Medium</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* With custom content */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Content</h2>
|
||||
<HStack gap={4}>
|
||||
<Button variant="primary">
|
||||
<span>🚀</span>
|
||||
<span style={{ marginLeft: "8px" }}>Launch</span>
|
||||
</Button>
|
||||
<Button variant="outline" style={{ flexDirection: "column", height: "80px", width: "96px" }}>
|
||||
<span style={{ fontSize: "24px" }}>💳</span>
|
||||
<span style={{ fontSize: "12px", marginTop: "4px" }}>Card</span>
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Native attributes work */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Native Attributes</h2>
|
||||
<HStack gap={4}>
|
||||
<Button onClick={() => alert("Clicked!")} variant="primary">
|
||||
Click Me
|
||||
</Button>
|
||||
<Button disabled variant="secondary" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button type="submit" variant="outline">
|
||||
Submit
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
61
src/divider.tsx
Normal file
61
src/divider.tsx
Normal file
|
|
@ -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<DividerProps> = ({ 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 (
|
||||
<div style={containerStyle}>
|
||||
<div style={lineStyle}></div>
|
||||
{children && (
|
||||
<>
|
||||
<span style={textStyle}>{children}</span>
|
||||
<div style={lineStyle}></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
return (
|
||||
<VStack gap={4} style={{ padding: "16px", maxWidth: "448px" }}>
|
||||
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>Divider Examples</h2>
|
||||
|
||||
<VStack gap={0}>
|
||||
<p>Would you like to continue</p>
|
||||
<Divider>OR WOULD YOU LIKE TO</Divider>
|
||||
<p>Submit to certain death</p>
|
||||
</VStack>
|
||||
|
||||
{/* Just a line */}
|
||||
<VStack gap={0}>
|
||||
<p>Look a line 👇</p>
|
||||
<Divider />
|
||||
<p>So cool, so straight!</p>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
158
src/grid.tsx
Normal file
158
src/grid.tsx
Normal file
|
|
@ -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<GridProps> = (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 <div style={combinedStyles}>{children}</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack gap={4} style={{ padding: "16px" }}>
|
||||
<VStack gap={6}>
|
||||
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>Grid Examples</h2>
|
||||
|
||||
{/* Simple 3-column grid */}
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Simple 3 columns: cols=3</h3>
|
||||
<Grid cols={3} gap={4}>
|
||||
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Item 1</div>
|
||||
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Item 2</div>
|
||||
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Item 3</div>
|
||||
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Item 4</div>
|
||||
<div style={{ backgroundColor: "#e9d5ff", padding: "16px", textAlign: "center" }}>Item 5</div>
|
||||
<div style={{ backgroundColor: "#fbcfe8", padding: "16px", textAlign: "center" }}>Item 6</div>
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
{/* Responsive grid */}
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
|
||||
Responsive: cols={sm: 1, md: 2, lg: 3}
|
||||
</h3>
|
||||
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap={4}>
|
||||
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Card 1</div>
|
||||
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Card 2</div>
|
||||
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Card 3</div>
|
||||
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Card 4</div>
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
{/* More responsive examples */}
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
|
||||
More responsive: cols={sm: 2, lg: 4, xl: 6}
|
||||
</h3>
|
||||
<Grid cols={{ sm: 2, lg: 4, xl: 6 }} gap={4}>
|
||||
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Item A</div>
|
||||
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Item B</div>
|
||||
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Item C</div>
|
||||
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Item D</div>
|
||||
<div style={{ backgroundColor: "#e9d5ff", padding: "16px", textAlign: "center" }}>Item E</div>
|
||||
<div style={{ backgroundColor: "#fbcfe8", padding: "16px", textAlign: "center" }}>Item F</div>
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
{/* Payment method example */}
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Payment buttons example</h3>
|
||||
<Grid cols={3} gap={4}>
|
||||
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
|
||||
<div style={{ fontSize: "24px" }}>💳</div>
|
||||
<span style={{ fontSize: "12px" }}>Card</span>
|
||||
</Button>
|
||||
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
|
||||
<div style={{ fontSize: "24px" }}>🍎</div>
|
||||
<span style={{ fontSize: "12px" }}>Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
|
||||
<div style={{ fontSize: "24px" }}>💰</div>
|
||||
<span style={{ fontSize: "12px" }}>PayPal</span>
|
||||
</Button>
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
{/* Alignment examples */}
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
|
||||
Alignment: v="center" h="center"
|
||||
</h3>
|
||||
<Grid cols={3} gap={4} v="center" h="center" style={{ height: "128px", backgroundColor: "#f3f4f6" }}>
|
||||
<div style={{ backgroundColor: "#fecaca", padding: "8px" }}>Item 1</div>
|
||||
<div style={{ backgroundColor: "#bbf7d0", padding: "8px" }}>Item 2</div>
|
||||
<div style={{ backgroundColor: "#bfdbfe", padding: "8px" }}>Item 3</div>
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2}>
|
||||
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Alignment: v="start" h="end"</h3>
|
||||
<Grid cols={2} gap={4} v="start" h="end" style={{ height: "96px", backgroundColor: "#f3f4f6" }}>
|
||||
<div style={{ backgroundColor: "#e9d5ff", padding: "8px" }}>Left</div>
|
||||
<div style={{ backgroundColor: "#fed7aa", padding: "8px" }}>Right</div>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
210
src/icon.tsx
Normal file
210
src/icon.tsx
Normal file
|
|
@ -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<IconProps> = (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(
|
||||
/<svg([^>]*)>/,
|
||||
`<svg$1 style="display: block; flex-shrink: 0; width: ${pixelSize}px; height: ${pixelSize}px;" class="${className || ""}">`
|
||||
)
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: modifiedSvg }} style={iconStyle} />
|
||||
}
|
||||
|
||||
export const IconLink: FC<IconLinkProps> = (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 (
|
||||
<a href={href} target={target} class={className} style={linkStyle}>
|
||||
<Icon {...iconProps} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
{/* === ICON TESTS === */}
|
||||
|
||||
{/* Size variations */}
|
||||
<div style={{ marginBottom: "32px" }}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Icon Size Variations</h2>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
{([3, 4, 5, 6, 8, 10, 12, 16] as const).map((size) => (
|
||||
<div key={size} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
|
||||
<Icon name="Heart" size={size} />
|
||||
<p style={{ fontSize: "14px" }}>{size}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styling with CSS classes */}
|
||||
<div style={{ marginBottom: "32px" }}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Styling with CSS Classes</h2>
|
||||
<Grid cols={5} gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Star" size={12} />
|
||||
<p style={{ fontSize: "14px" }}>Default</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Star" size={12} style={{ color: "#3b82f6" }} />
|
||||
<p style={{ fontSize: "14px" }}>Blue Color</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Star" size={12} class="stroke-1" />
|
||||
<p style={{ fontSize: "14px" }}>Thin Stroke</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Star" size={12} style={{ color: "#fbbf24", fill: "currentColor", stroke: "none" }} />
|
||||
<p style={{ fontSize: "14px" }}>Filled</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Star" size={12} style={{ color: "#a855f7", transition: "color 0.2s" }} />
|
||||
<p style={{ fontSize: "14px" }}>Hover Effect</p>
|
||||
</VStack>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Advanced styling */}
|
||||
<div style={{ marginBottom: "32px" }}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Advanced Styling</h2>
|
||||
<Grid cols={4} gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Heart" size={12} style={{ color: "#ef4444", fill: "currentColor", stroke: "none" }} />
|
||||
<p style={{ fontSize: "14px" }}>Filled Heart</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Shield" size={12} style={{ color: "#16a34a", strokeWidth: "2" }} />
|
||||
<p style={{ fontSize: "14px" }}>Thick Stroke</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Sun" size={12} style={{ color: "#eab308" }} />
|
||||
<p style={{ fontSize: "14px" }}>Sun Icon</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Icon name="Zap" size={12} style={{ color: "#60a5fa", filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.3))" }} />
|
||||
<p style={{ fontSize: "14px" }}>Drop Shadow</p>
|
||||
</VStack>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* === ICON LINK TESTS === */}
|
||||
|
||||
{/* Basic icon links */}
|
||||
<div style={{ marginBottom: "32px" }}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Icon Links</h2>
|
||||
<div style={{ display: "flex", gap: "24px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
|
||||
<IconLink name="Home" size={8} href="/" />
|
||||
<p style={{ fontSize: "14px" }}>Home Link</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
|
||||
<IconLink name="ExternalLink" size={8} href="https://example.com" target="_blank" />
|
||||
<p style={{ fontSize: "14px" }}>External Link</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
|
||||
<IconLink name="Mail" size={8} href="mailto:hello@example.com" />
|
||||
<p style={{ fontSize: "14px" }}>Email Link</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
|
||||
<IconLink name="Phone" size={8} href="tel:+1234567890" />
|
||||
<p style={{ fontSize: "14px" }}>Phone Link</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styled icon links */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Styled Icon Links</h2>
|
||||
<Grid cols={4} gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<IconLink
|
||||
name="Download"
|
||||
size={8}
|
||||
href="#"
|
||||
style={{
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "white",
|
||||
padding: "8px",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Button Style</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<IconLink
|
||||
name="Settings"
|
||||
size={8}
|
||||
href="#"
|
||||
style={{
|
||||
border: "2px solid #d1d5db",
|
||||
padding: "8px",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Circle Border</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<IconLink name="Heart" size={8} href="#" style={{ color: "#ef4444" }} />
|
||||
<p style={{ fontSize: "14px" }}>Red Heart</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<IconLink name="Star" size={8} href="#" style={{ color: "#fbbf24", fill: "currentColor" }} />
|
||||
<p style={{ fontSize: "14px" }}>Filled Star</p>
|
||||
</VStack>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sizeToPixels(size: number): number {
|
||||
return size * 4
|
||||
}
|
||||
219
src/image.tsx
Normal file
219
src/image.tsx
Normal file
|
|
@ -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<ImageProps> = ({ src, alt = "", width, height, objectFit, style }) => {
|
||||
const imageStyle: JSX.CSSProperties = {
|
||||
width: width ? `${width}px` : undefined,
|
||||
height: height ? `${height}px` : undefined,
|
||||
objectFit: objectFit,
|
||||
...style,
|
||||
}
|
||||
|
||||
return <img src={src} alt={alt} style={imageStyle} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack gap={8} style={{ padding: "24px" }}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Image Examples</h2>
|
||||
|
||||
{/* Size variations */}
|
||||
<VStack gap={4}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Size Variations</h3>
|
||||
<HStack gap={4} wrap>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image src={sampleImages[0]!} width={64} height={64} objectFit="cover" alt="64x64" />
|
||||
<p style={{ fontSize: "14px" }}>64x64</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image src={sampleImages[0]!} width={96} height={96} objectFit="cover" alt="96x96" />
|
||||
<p style={{ fontSize: "14px" }}>96x96</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image src={sampleImages[0]!} width={128} height={128} objectFit="cover" alt="128x128" />
|
||||
<p style={{ fontSize: "14px" }}>128x128</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image src={sampleImages[0]!} width={192} height={128} objectFit="cover" alt="192x128" />
|
||||
<p style={{ fontSize: "14px" }}>192x128</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Object fit variations */}
|
||||
<VStack gap={4}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Object Fit Variations</h3>
|
||||
<p style={{ fontSize: "14px", color: "#6b7280" }}>
|
||||
Same image with different object-fit values
|
||||
</p>
|
||||
<HStack gap={6} wrap>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="cover"
|
||||
style={{ border: "1px solid black" }}
|
||||
alt="Object cover"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>object-fit: cover</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="contain"
|
||||
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
|
||||
alt="Object contain"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>object-fit: contain</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="fill"
|
||||
style={{ border: "1px solid black" }}
|
||||
alt="Object fill"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>object-fit: fill</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="scale-down"
|
||||
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
|
||||
alt="Object scale-down"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>object-fit: scale-down</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="none"
|
||||
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
|
||||
alt="Object none"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>object-fit: none</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Styling examples */}
|
||||
<VStack gap={4}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Styling Examples</h3>
|
||||
<HStack gap={6} wrap>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="cover"
|
||||
style={{ borderRadius: "8px", border: "4px solid #3b82f6" }}
|
||||
alt="Rounded with border"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Rounded + Border</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[1]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="cover"
|
||||
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
|
||||
alt="With shadow"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>With Shadow</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Image
|
||||
src={sampleImages[2]!}
|
||||
width={128}
|
||||
height={128}
|
||||
objectFit="cover"
|
||||
style={{
|
||||
borderRadius: "9999px",
|
||||
border: "4px solid #22c55e",
|
||||
boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
alt="Circular with effects"
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Circular + Effects</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Common use cases */}
|
||||
<VStack gap={6}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Common Use Cases</h3>
|
||||
|
||||
{/* Avatar */}
|
||||
<VStack gap={2}>
|
||||
<h4 style={{ fontWeight: "500" }}>Avatar</h4>
|
||||
<Image
|
||||
src={sampleImages[0]!}
|
||||
width={48}
|
||||
height={48}
|
||||
objectFit="cover"
|
||||
style={{ borderRadius: "9999px" }}
|
||||
alt="Avatar"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* Card image */}
|
||||
<VStack gap={2} style={{ maxWidth: "384px" }}>
|
||||
<h4 style={{ fontWeight: "500" }}>Card Image</h4>
|
||||
<VStack gap={0} style={{ border: "1px solid #d1d5db", borderRadius: "8px", overflow: "hidden" }}>
|
||||
<Image src={sampleImages[1]!} width={384} height={192} objectFit="cover" alt="Card image" />
|
||||
<VStack gap={1} style={{ padding: "16px" }}>
|
||||
<h5 style={{ fontWeight: "500" }}>Card Title</h5>
|
||||
<p style={{ fontSize: "14px", color: "#6b7280" }}>Card description goes here</p>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Gallery grid */}
|
||||
<VStack gap={2}>
|
||||
<h4 style={{ fontWeight: "500" }}>Gallery Grid</h4>
|
||||
<Grid cols={3} gap={2}>
|
||||
{sampleImages.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={src}
|
||||
width={120}
|
||||
height={120}
|
||||
objectFit="cover"
|
||||
style={{ borderRadius: "4px" }}
|
||||
alt={`Gallery ${i}`}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
28
src/index.tsx
Normal file
28
src/index.tsx
Normal file
|
|
@ -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"
|
||||
181
src/input.tsx
Normal file
181
src/input.tsx
Normal file
|
|
@ -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<InputProps> = (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 <input style={inputStyle} {...inputProps} />
|
||||
}
|
||||
|
||||
const labelStyle: JSX.CSSProperties = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
color: "#111827",
|
||||
}
|
||||
|
||||
const labelElement = (
|
||||
<label for={inputProps.id} style={labelStyle}>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (labelPosition === "above") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
|
||||
{labelElement}
|
||||
<input style={inputStyle} {...inputProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (labelPosition === "left") {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
|
||||
{labelElement}
|
||||
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (labelPosition === "right") {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
|
||||
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} />
|
||||
{labelElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
return (
|
||||
<VStack gap={8} style={{ padding: "24px", maxWidth: "448px" }}>
|
||||
{/* Basic inputs */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Basic Inputs</h2>
|
||||
<VStack gap={4}>
|
||||
<Input placeholder="Enter your name" />
|
||||
<Input type="email" placeholder="Enter your email" />
|
||||
<Input type="password" placeholder="Enter your password" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Custom styling */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Styling</h2>
|
||||
<VStack gap={4}>
|
||||
<Input style={{ height: "32px", fontSize: "12px" }} placeholder="Small input" />
|
||||
<Input placeholder="Default input" />
|
||||
<Input style={{ height: "48px", fontSize: "16px" }} placeholder="Large input" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* With values */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Values</h2>
|
||||
<VStack gap={4}>
|
||||
<Input value="John Doe" placeholder="Name" />
|
||||
<Input type="email" value="john@example.com" placeholder="Email" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Disabled state */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Disabled State</h2>
|
||||
<VStack gap={4}>
|
||||
<Input disabled placeholder="Disabled input" style={{ opacity: 0.5, cursor: "not-allowed" }} />
|
||||
<Input disabled value="Disabled with value" style={{ opacity: 0.5, cursor: "not-allowed" }} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label above */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Above</h2>
|
||||
<VStack gap={4}>
|
||||
<Input placeholder="Enter your name">Name</Input>
|
||||
<Input type="email" placeholder="Enter your email">
|
||||
Email
|
||||
</Input>
|
||||
<Input type="password" placeholder="Enter your password">
|
||||
Password
|
||||
</Input>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label to the left */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Left</h2>
|
||||
<VStack gap={4}>
|
||||
<Input labelPosition="left" placeholder="Enter your name">
|
||||
Name
|
||||
</Input>
|
||||
<Input labelPosition="left" type="email" placeholder="Enter your email">
|
||||
Email
|
||||
</Input>
|
||||
<Input labelPosition="left" type="password" placeholder="Enter your password">
|
||||
Password
|
||||
</Input>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label to the right */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Right</h2>
|
||||
<VStack gap={4}>
|
||||
<Input labelPosition="right" placeholder="Enter your name">
|
||||
Name
|
||||
</Input>
|
||||
<Input labelPosition="right" type="email" placeholder="Enter your email">
|
||||
Email
|
||||
</Input>
|
||||
<Input labelPosition="right" type="password" placeholder="Enter your password">
|
||||
Password
|
||||
</Input>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Horizontal layout */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Horizontal Layout</h2>
|
||||
<HStack gap={4}>
|
||||
<Input placeholder="First name">First</Input>
|
||||
<Input placeholder="Last name">Last</Input>
|
||||
<Input placeholder="Age">Age</Input>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Custom styling */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Input Styling</h2>
|
||||
<VStack gap={4}>
|
||||
<Input style={{ borderColor: "#93c5fd" }} placeholder="Custom styled input">
|
||||
<span style={{ color: "#2563eb", fontWeight: "bold" }}>Custom Label</span>
|
||||
</Input>
|
||||
<Input labelPosition="left" placeholder="Required input">
|
||||
<span style={{ color: "#dc2626", minWidth: "96px" }}>Required Field</span>
|
||||
</Input>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
245
src/placeholder.tsx
Normal file
245
src/placeholder.tsx
Normal file
|
|
@ -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 <Avatar src={url.toString()} alt={alt} style={style} size={size} rounded={rounded} />
|
||||
},
|
||||
|
||||
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 <Image src={src} alt={alt} width={width} height={height} objectFit={objectFit} style={style} />
|
||||
},
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
return (
|
||||
<VStack gap={8} style={{ padding: "24px" }}>
|
||||
{/* === AVATAR TESTS === */}
|
||||
|
||||
{/* Show all available avatar styles */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>
|
||||
All Avatar Styles ({allStyles.length} total)
|
||||
</h2>
|
||||
<Grid cols={10} gap={3}>
|
||||
{allStyles.map((style) => (
|
||||
<VStack h="center" gap={1} key={style}>
|
||||
<Placeholder.Avatar type={style} size={48} />
|
||||
<p style={{ fontSize: "12px", fontWeight: "500" }}>{style}</p>
|
||||
</VStack>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
|
||||
{/* Avatar size variations */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Avatar Size Variations</h2>
|
||||
<HStack gap={4}>
|
||||
{[24, 32, 48, 64].map((size) => (
|
||||
<VStack h="center" gap={2} key={size}>
|
||||
<Placeholder.Avatar size={size} />
|
||||
<p style={{ fontSize: "14px" }}>{size}px</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Avatar styling combinations */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Avatar Styling Options</h2>
|
||||
<HStack gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Placeholder.Avatar rounded size={64} />
|
||||
<p style={{ fontSize: "14px" }}>Rounded + Background</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<div style={{ backgroundColor: "#e5e7eb", padding: "8px" }}>
|
||||
<Placeholder.Avatar rounded transparent size={64} />
|
||||
</div>
|
||||
<p style={{ fontSize: "14px" }}>Rounded + Transparent</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Placeholder.Avatar size={64} />
|
||||
<p style={{ fontSize: "14px" }}>Square + Background</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<div style={{ backgroundColor: "#e5e7eb", padding: "8px" }}>
|
||||
<Placeholder.Avatar transparent size={64} />
|
||||
</div>
|
||||
<p style={{ fontSize: "14px" }}>Square + Transparent</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Avatar seed variations */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>
|
||||
Avatar Seeds (Same Style, Different People)
|
||||
</h2>
|
||||
<HStack gap={4}>
|
||||
{["alice", "bob", "charlie", "diana"].map((seed) => (
|
||||
<VStack h="center" gap={2} key={seed}>
|
||||
<Placeholder.Avatar seed={seed} size={64} />
|
||||
<p style={{ fontSize: "14px" }}>"{seed}"</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* === IMAGE TESTS === */}
|
||||
<VStack gap={6}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Placeholder Images</h2>
|
||||
|
||||
{/* Size variations */}
|
||||
<VStack gap={3}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Size Variations</h3>
|
||||
<HStack gap={4}>
|
||||
{[
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 150, height: 100 },
|
||||
{ width: 200, height: 150 },
|
||||
{ width: 250, height: 200 },
|
||||
].map(({ width, height }) => (
|
||||
<VStack h="center" gap={2} key={`${width}x${height}`}>
|
||||
<Placeholder.Image width={width} height={height} seed={1} />
|
||||
<p style={{ fontSize: "14px" }}>
|
||||
{width}×{height}
|
||||
</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Different seeds - show variety */}
|
||||
<VStack gap={3}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>
|
||||
Different Images (Different Seeds)
|
||||
</h3>
|
||||
<HStack gap={4}>
|
||||
{[1, 2, 3, 4, 5].map((seed) => (
|
||||
<VStack h="center" gap={2} key={seed}>
|
||||
<Placeholder.Image width={150} height={150} seed={seed} />
|
||||
<p style={{ fontSize: "14px" }}>Seed {seed}</p>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* With custom styles */}
|
||||
<VStack gap={3}>
|
||||
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>With Custom Styles</h3>
|
||||
<HStack gap={6}>
|
||||
<VStack h="center" gap={2}>
|
||||
<Placeholder.Image
|
||||
width={150}
|
||||
height={150}
|
||||
seed={1}
|
||||
objectFit="cover"
|
||||
style={{ borderRadius: "8px", border: "4px solid #3b82f6" }}
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Rounded + Border</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Placeholder.Image
|
||||
width={150}
|
||||
height={150}
|
||||
seed={2}
|
||||
objectFit="cover"
|
||||
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>With Shadow</p>
|
||||
</VStack>
|
||||
<VStack h="center" gap={2}>
|
||||
<Placeholder.Image
|
||||
width={150}
|
||||
height={150}
|
||||
seed={3}
|
||||
objectFit="cover"
|
||||
style={{
|
||||
borderRadius: "9999px",
|
||||
border: "4px solid #22c55e",
|
||||
boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: "14px" }}>Circular + Effects</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
type PlaceholderAvatarProps = Omit<AvatarProps, "src"> & {
|
||||
seed?: string
|
||||
type?: DicebearStyleName
|
||||
transparent?: boolean
|
||||
}
|
||||
|
||||
type PlaceholderImageProps = Omit<ImageProps, "src" | "alt"> & {
|
||||
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
|
||||
263
src/select.tsx
Normal file
263
src/select.tsx
Normal file
|
|
@ -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<JSX.IntrinsicElements["select"], "children"> & {
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
labelPosition?: "above" | "left" | "right"
|
||||
children?: any
|
||||
}
|
||||
|
||||
export const Select: FC<SelectProps> = (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 = (
|
||||
<select style={selectStyle} {...selectProps}>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
selected={selectProps.value === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
|
||||
if (!children) {
|
||||
return selectElement
|
||||
}
|
||||
|
||||
const labelStyle: JSX.CSSProperties = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
color: "#111827",
|
||||
}
|
||||
|
||||
const labelElement = (
|
||||
<label for={selectProps.id} style={labelStyle}>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (labelPosition === "above") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
|
||||
{labelElement}
|
||||
{selectElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (labelPosition === "left") {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
|
||||
{labelElement}
|
||||
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (labelPosition === "right") {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
|
||||
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{labelElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack gap={8} style={{ padding: "24px", maxWidth: "448px" }}>
|
||||
{/* Basic selects */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Basic Selects</h2>
|
||||
<VStack gap={4}>
|
||||
<Select options={months} placeholder="Select month" />
|
||||
<Select options={years} placeholder="Select year" />
|
||||
<Select options={countries} placeholder="Select country" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* With values */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Values</h2>
|
||||
<VStack gap={4}>
|
||||
<Select options={months} value="03" />
|
||||
<Select options={years} value="2025" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Disabled state */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Disabled State</h2>
|
||||
<VStack gap={4}>
|
||||
<Select
|
||||
options={months}
|
||||
disabled
|
||||
placeholder="Disabled select"
|
||||
style={{ opacity: 0.5, cursor: "not-allowed" }}
|
||||
/>
|
||||
<Select options={years} disabled value="2024" style={{ opacity: 0.5, cursor: "not-allowed" }} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Custom styling */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Styling</h2>
|
||||
<VStack gap={4}>
|
||||
<Select options={countries} style={{ borderColor: "#93c5fd" }} placeholder="Custom styled select" />
|
||||
<Select options={months} style={{ height: "32px", fontSize: "12px" }} placeholder="Small select" />
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label above */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Above</h2>
|
||||
<VStack gap={4}>
|
||||
<Select options={months} placeholder="Select month">
|
||||
Birth Month
|
||||
</Select>
|
||||
<Select options={years} placeholder="Select year">
|
||||
Birth Year
|
||||
</Select>
|
||||
<Select options={countries} placeholder="Select country">
|
||||
Country
|
||||
</Select>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label to the left */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Left</h2>
|
||||
<VStack gap={4}>
|
||||
<Select labelPosition="left" options={months} placeholder="Select month">
|
||||
Month
|
||||
</Select>
|
||||
<Select labelPosition="left" options={years} placeholder="Select year">
|
||||
Year
|
||||
</Select>
|
||||
<Select labelPosition="left" options={countries} placeholder="Select country">
|
||||
Country
|
||||
</Select>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Label to the right */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Right</h2>
|
||||
<VStack gap={4}>
|
||||
<Select labelPosition="right" options={months} placeholder="Select month">
|
||||
Month
|
||||
</Select>
|
||||
<Select labelPosition="right" options={years} placeholder="Select year">
|
||||
Year
|
||||
</Select>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* Horizontal layout (like card form) */}
|
||||
<VStack gap={4}>
|
||||
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Horizontal Layout</h2>
|
||||
<HStack gap={4}>
|
||||
<Select options={months} placeholder="MM">
|
||||
Expires
|
||||
</Select>
|
||||
<Select options={years} placeholder="YYYY">
|
||||
Year
|
||||
</Select>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
193
src/stack.tsx
Normal file
193
src/stack.tsx
Normal file
|
|
@ -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<VStackProps> = (props) => {
|
||||
return (
|
||||
<Stack
|
||||
direction="col"
|
||||
mainAxis={props.v}
|
||||
crossAxis={props.h}
|
||||
wrap={props.wrap}
|
||||
gap={props.gap}
|
||||
style={props.style}
|
||||
>
|
||||
{props.children}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const HStack: FC<HStackProps> = (props) => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
mainAxis={props.h}
|
||||
crossAxis={props.v}
|
||||
wrap={props.wrap}
|
||||
gap={props.gap}
|
||||
style={props.style}
|
||||
>
|
||||
{props.children}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const Stack: FC<StackProps> = (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 <div style={combinedStyles}>{props.children}</div>
|
||||
}
|
||||
|
||||
export const Test = () => {
|
||||
const mainAxisOpts: MainAxisOpts[] = ["start", "center", "end", "between", "around", "evenly"]
|
||||
const crossAxisOpts: CrossAxisOpts[] = ["start", "center", "end", "stretch", "baseline"]
|
||||
|
||||
return (
|
||||
<VStack gap={8} style={{ padding: "16px" }}>
|
||||
{/* HStack layout matrix */}
|
||||
<VStack gap={2}>
|
||||
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>HStack Layout</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Grid cols={7} gap={1} style={{ gridTemplateColumns: "auto repeat(6, 1fr)" }}>
|
||||
{/* Header row: blank + h labels */}
|
||||
<div></div>
|
||||
{mainAxisOpts.map((h) => (
|
||||
<div key={h} style={{ fontSize: "14px", fontWeight: "500", textAlign: "center" }}>
|
||||
h: {h}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Each row: v label + HStack cells */}
|
||||
{crossAxisOpts.map((v) => [
|
||||
<div key={v} style={{ fontSize: "14px", fontWeight: "500" }}>
|
||||
v: {v}
|
||||
</div>,
|
||||
...mainAxisOpts.map((h) => (
|
||||
<HStack
|
||||
key={`${h}-${v}`}
|
||||
h={h}
|
||||
v={v}
|
||||
style={{ backgroundColor: "#f3f4f6", padding: "8px", height: "96px", border: "1px solid #9ca3af" }}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#ef4444" }}>Aa</div>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#22c55e" }}>Aa</div>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#3b82f6" }}>Aa</div>
|
||||
</HStack>
|
||||
)),
|
||||
])}
|
||||
</Grid>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* VStack layout matrix */}
|
||||
<VStack gap={2}>
|
||||
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>VStack Layout</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Grid cols={6} gap={1} style={{ gridTemplateColumns: "auto repeat(5, 1fr)" }}>
|
||||
{/* Header row: blank + h labels */}
|
||||
<div></div>
|
||||
{crossAxisOpts.map((h) => (
|
||||
<div key={h} style={{ fontSize: "14px", fontWeight: "500", textAlign: "center" }}>
|
||||
h: {h}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Each row: v label + VStack cells */}
|
||||
{mainAxisOpts.map((v) => [
|
||||
<div key={v} style={{ fontSize: "14px", fontWeight: "500" }}>
|
||||
v: {v}
|
||||
</div>,
|
||||
...crossAxisOpts.map((h) => (
|
||||
<VStack
|
||||
key={`${h}-${v}`}
|
||||
v={v}
|
||||
h={h}
|
||||
style={{ backgroundColor: "#f3f4f6", padding: "8px", height: "168px", border: "1px solid #9ca3af" }}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#ef4444" }}>Aa</div>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#22c55e" }}>Aa</div>
|
||||
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#3b82f6" }}>Aa</div>
|
||||
</VStack>
|
||||
)),
|
||||
])}
|
||||
</Grid>
|
||||
</div>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
stretch: "stretch",
|
||||
baseline: "baseline",
|
||||
}
|
||||
return map[axis] || "stretch"
|
||||
}
|
||||
1
src/types.ts
Normal file
1
src/types.ts
Normal file
|
|
@ -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
|
||||
37
test/server.tsx
Normal file
37
test/server.tsx
Normal file
|
|
@ -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(<><h1>{file}</h1><page.Test req={c.req} /></>)
|
||||
})
|
||||
|
||||
app.get('/', c => {
|
||||
return c.html(<>
|
||||
<h1>Test Files</h1>
|
||||
<ul style='font-size:150%'>{testFiles().map(x => <li><a href={`/${x}`}>{x}</a></li>)}</ul>
|
||||
</>)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
|
|
@ -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/*"
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user