409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
|
||
import {
|
||
Check,
|
||
ChevronDown,
|
||
CircleAlert,
|
||
CircleCheck,
|
||
Info,
|
||
Moon,
|
||
Sun,
|
||
TriangleAlert,
|
||
} from 'lucide-solid'
|
||
import {
|
||
CheckboxRoot,
|
||
CheckboxControl,
|
||
CheckboxIndicator,
|
||
CheckboxLabel,
|
||
CheckboxHiddenInput,
|
||
} from '@ark-ui/solid/checkbox'
|
||
import {
|
||
RadioGroupRoot,
|
||
RadioGroupItem,
|
||
RadioGroupItemControl,
|
||
RadioGroupItemText,
|
||
RadioGroupItemHiddenInput,
|
||
RadioGroupLabel,
|
||
} from '@ark-ui/solid/radio-group'
|
||
import { theme, toggleTheme } from '../lib/theme'
|
||
|
||
export function Card(props: { class?: string; children: JSX.Element }) {
|
||
return <div class={`card ${props.class ?? ''}`}>{props.children}</div>
|
||
}
|
||
|
||
export function CardHeader(props: { class?: string; children: JSX.Element }) {
|
||
return <div class={`flex flex-col gap-1 p-5 pb-0 ${props.class ?? ''}`}>{props.children}</div>
|
||
}
|
||
|
||
export function CardTitle(props: { class?: string; children: JSX.Element }) {
|
||
return <h3 class={`font-semibold leading-none ${props.class ?? ''}`}>{props.children}</h3>
|
||
}
|
||
|
||
export function CardDescription(props: { class?: string; children: JSX.Element }) {
|
||
return <p class={`text-sm text-muted-foreground ${props.class ?? ''}`}>{props.children}</p>
|
||
}
|
||
|
||
export function CardContent(props: { class?: string; children: JSX.Element }) {
|
||
return <div class={`p-5 pt-4 ${props.class ?? ''}`}>{props.children}</div>
|
||
}
|
||
|
||
export function CardFooter(props: { class?: string; children: JSX.Element }) {
|
||
return <div class={`flex items-center p-5 pt-0 ${props.class ?? ''}`}>{props.children}</div>
|
||
}
|
||
|
||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||
export type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
|
||
|
||
const BTN_VARIANT: Record<ButtonVariant, string> = {
|
||
primary: 'btn-primary',
|
||
secondary: 'btn-secondary',
|
||
outline: 'btn-outline',
|
||
ghost: 'btn-ghost',
|
||
danger: 'btn-destructive',
|
||
}
|
||
|
||
const BTN_SIZE: Record<ButtonSize, string> = {
|
||
sm: 'btn-sm',
|
||
default: '',
|
||
lg: 'btn-lg',
|
||
icon: 'btn-icon',
|
||
}
|
||
|
||
export function Button(props: {
|
||
onClick?: () => void
|
||
variant?: ButtonVariant
|
||
size?: ButtonSize
|
||
disabled?: boolean
|
||
class?: string
|
||
type?: 'button' | 'submit'
|
||
'aria-label'?: string
|
||
title?: string
|
||
children: JSX.Element
|
||
}) {
|
||
return (
|
||
<button
|
||
type={props.type ?? 'button'}
|
||
class={`btn ${BTN_VARIANT[props.variant ?? 'primary']} ${BTN_SIZE[props.size ?? 'default']} ${props.class ?? ''}`}
|
||
disabled={props.disabled}
|
||
aria-label={props['aria-label']}
|
||
title={props.title}
|
||
onClick={props.onClick}
|
||
>
|
||
{props.children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function TextInput(props: {
|
||
id?: string
|
||
value?: string
|
||
onInput?: (v: string) => void
|
||
onKeyDown?: (e: KeyboardEvent) => void
|
||
placeholder?: string
|
||
type?: string
|
||
class?: string
|
||
disabled?: boolean
|
||
name?: string
|
||
autoComplete?: string
|
||
'aria-label'?: string
|
||
}) {
|
||
return (
|
||
<input
|
||
id={props.id}
|
||
class={`field ${props.class ?? ''}`}
|
||
type={props.type ?? 'text'}
|
||
name={props.name}
|
||
autocomplete={props.autoComplete}
|
||
placeholder={props.placeholder}
|
||
value={props.value ?? ''}
|
||
disabled={props.disabled}
|
||
aria-label={props['aria-label']}
|
||
onInput={(e) => props.onInput?.((e.currentTarget as HTMLInputElement).value)}
|
||
onKeyDown={props.onKeyDown}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function Select(props: {
|
||
value: string
|
||
onChange: (v: string) => void
|
||
options: { value: string; label: string }[]
|
||
class?: string
|
||
disabled?: boolean
|
||
'aria-label'?: string
|
||
}) {
|
||
return (
|
||
<div class={`relative ${props.class ?? ''}`}>
|
||
<select
|
||
class="field field-mono h-9 appearance-none pr-8 text-xs"
|
||
value={props.value}
|
||
disabled={props.disabled}
|
||
aria-label={props['aria-label']}
|
||
onChange={(e) => props.onChange((e.currentTarget as HTMLSelectElement).value)}
|
||
>
|
||
<For each={props.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
|
||
</select>
|
||
<ChevronDown class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger' | 'muted'
|
||
|
||
const BADGE_TONE: Record<BadgeVariant, string> = {
|
||
default: 'border-transparent bg-primary text-primary-foreground',
|
||
secondary: 'border-line bg-secondary text-secondary-foreground',
|
||
outline: 'border-line text-foreground',
|
||
success: 'border-success/30 bg-success/10 text-success',
|
||
warning: 'border-warning/30 bg-warning/10 text-warning',
|
||
danger: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||
muted: 'border-line bg-transparent text-muted-foreground',
|
||
}
|
||
|
||
export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) {
|
||
return <span class={`badge ${BADGE_TONE[props.variant ?? 'muted']} ${props.class ?? ''}`}>{props.children}</span>
|
||
}
|
||
|
||
const ALERT_TONE = {
|
||
error: {
|
||
box: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||
icon: CircleAlert,
|
||
},
|
||
warning: {
|
||
box: 'border-warning/40 bg-warning/10 text-warning',
|
||
icon: TriangleAlert,
|
||
},
|
||
success: {
|
||
box: 'border-success/40 bg-success/10 text-success',
|
||
icon: CircleCheck,
|
||
},
|
||
info: {
|
||
box: 'border-acid-cyan/40 bg-acid-cyan/10 text-accent-foreground',
|
||
icon: Info,
|
||
},
|
||
}
|
||
|
||
export function Alert(props: {
|
||
tone?: 'error' | 'warning' | 'success' | 'info'
|
||
class?: string
|
||
children: JSX.Element
|
||
}) {
|
||
const tone = props.tone ?? 'info'
|
||
const style = ALERT_TONE[tone]
|
||
const Icon = style.icon
|
||
return (
|
||
<div
|
||
role={tone === 'error' ? 'alert' : undefined}
|
||
class={`flex items-start gap-2.5 border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`}
|
||
>
|
||
<Icon class="mt-px size-4 shrink-0" />
|
||
<div class="min-w-0 flex-1">{props.children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function LiveDot(props: { active?: boolean; class?: string }) {
|
||
return (
|
||
<span
|
||
class={`inline-block size-2 shrink-0 ${
|
||
props.active ? 'bg-success pulse' : 'bg-muted-foreground/40'
|
||
} ${props.class ?? ''}`}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export function Separator(props: { class?: string }) {
|
||
return <div role="separator" class={`h-px w-full shrink-0 bg-border ${props.class ?? ''}`} />
|
||
}
|
||
|
||
export function ThemeToggle(props: { class?: string }) {
|
||
const next = () => (theme() === 'dark' ? '亮色' : '暗色')
|
||
return (
|
||
<button
|
||
type="button"
|
||
class={`btn btn-ghost btn-icon ${props.class ?? ''}`}
|
||
onClick={toggleTheme}
|
||
aria-label={`切换到${next()}模式`}
|
||
title={`切换到${next()}模式`}
|
||
>
|
||
<Show when={theme() === 'dark'} fallback={<Moon class="size-4" />}>
|
||
<Sun class="size-4" />
|
||
</Show>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function CheckboxRow(props: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||
return (
|
||
<CheckboxRoot
|
||
checked={props.checked}
|
||
disabled={props.disabled}
|
||
onCheckedChange={(e: any) => props.onChange(!!e.checked)}
|
||
class="group flex cursor-pointer items-center gap-2.5 select-none"
|
||
>
|
||
<CheckboxControl
|
||
class={`flex size-4 shrink-0 items-center justify-center border transition-colors ${
|
||
props.checked
|
||
? 'border-primary bg-primary text-primary-foreground'
|
||
: 'border-input bg-card text-transparent group-hover:border-primary/60'
|
||
}`}
|
||
>
|
||
<CheckboxIndicator class="leading-none">
|
||
<Check class="size-3.5" strokeWidth={3} />
|
||
</CheckboxIndicator>
|
||
</CheckboxControl>
|
||
<CheckboxLabel class="text-sm leading-none text-foreground">{props.label}</CheckboxLabel>
|
||
<CheckboxHiddenInput />
|
||
</CheckboxRoot>
|
||
)
|
||
}
|
||
|
||
function RadioRow(props: { value: string; label: string }) {
|
||
return (
|
||
<RadioGroupItem value={props.value} class="group flex cursor-pointer items-center gap-2.5 select-none">
|
||
<RadioGroupItemControl class="flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-card shadow-sm transition-colors data-[state=checked]:border-primary">
|
||
<span class="size-2 rounded-full bg-primary opacity-0 transition-opacity group-data-[state=checked]:opacity-100" />
|
||
</RadioGroupItemControl>
|
||
<RadioGroupItemText class="text-sm leading-none text-foreground">{props.label}</RadioGroupItemText>
|
||
<RadioGroupItemHiddenInput />
|
||
</RadioGroupItem>
|
||
)
|
||
}
|
||
|
||
export function RadioPicker(props: {
|
||
label: string
|
||
value: string
|
||
onChange: (v: string) => void
|
||
options: { value: string; label: string }[]
|
||
}) {
|
||
return (
|
||
<RadioGroupRoot
|
||
value={props.value}
|
||
onValueChange={(e: any) => props.onChange(e.value)}
|
||
class="flex flex-col gap-2"
|
||
>
|
||
<RadioGroupLabel class="label">{props.label}</RadioGroupLabel>
|
||
<For each={props.options}>{(o) => <RadioRow value={o.value} label={o.label} />}</For>
|
||
</RadioGroupRoot>
|
||
)
|
||
}
|
||
|
||
export function Segmented<T extends string>(props: {
|
||
label?: string
|
||
value: T
|
||
onChange: (v: T) => void
|
||
options: { value: T; label: string; icon?: JSX.Element }[]
|
||
class?: string
|
||
}) {
|
||
return (
|
||
<div class={props.class ?? ''}>
|
||
<Show when={props.label}>
|
||
<span class="micro-label mb-2 inline-block text-muted-foreground">{props.label}</span>
|
||
</Show>
|
||
<div class="inline-flex max-w-full border border-line">
|
||
<For each={props.options}>
|
||
{(o) => (
|
||
<button
|
||
type="button"
|
||
class={`relative inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 border-r border-line px-3 font-mono text-[11px] tracking-[0.14em] uppercase whitespace-nowrap transition-colors last:border-r-0 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${
|
||
o.value === props.value
|
||
? 'bg-acid-lime/15 text-foreground dark:bg-primary/10 dark:text-primary'
|
||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||
}`}
|
||
onClick={() => props.onChange(o.value)}
|
||
>
|
||
{o.icon}
|
||
{o.label}
|
||
<Show when={o.value === props.value}>
|
||
<span class="absolute inset-x-0 top-0 h-0.5 bg-foreground dark:bg-primary" />
|
||
</Show>
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ===== HUD 四角(父元素需 relative) ===== */
|
||
export function Corners(props: { class?: string }) {
|
||
const c = props.class ?? ''
|
||
return (
|
||
<>
|
||
<span class={`hud-corner hud-tl ${c}`} aria-hidden="true" />
|
||
<span class={`hud-corner hud-tr ${c}`} aria-hidden="true" />
|
||
<span class={`hud-corner hud-bl ${c}`} aria-hidden="true" />
|
||
<span class={`hud-corner hud-br ${c}`} aria-hidden="true" />
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ===== 信号电平条 ===== */
|
||
export function Meter(props: { active?: boolean; class?: string }) {
|
||
return (
|
||
<span class={`meter ${props.active ? '' : 'idle'} ${props.class ?? ''}`} aria-hidden="true">
|
||
<i />
|
||
<i />
|
||
<i />
|
||
<i />
|
||
<i />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/* ===== 滚动入场(IntersectionObserver) ===== */
|
||
export function Reveal(props: { class?: string; delay?: number; children: JSX.Element }) {
|
||
let el: HTMLDivElement | undefined
|
||
onMount(() => {
|
||
const node = el
|
||
if (!node) return
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
node.classList.add('is-in')
|
||
return
|
||
}
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) {
|
||
node.classList.add('is-in')
|
||
io.disconnect()
|
||
}
|
||
}
|
||
},
|
||
{ threshold: 0.08, rootMargin: '0px 0px -6% 0px' },
|
||
)
|
||
io.observe(node)
|
||
onCleanup(() => io.disconnect())
|
||
})
|
||
return (
|
||
<div ref={(n) => (el = n)} class={`reveal ${props.class ?? ''}`} style={{ '--i': String(props.delay ?? 0) } as any}>
|
||
{props.children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ===== 跑马灯(内容复制两份实现无缝循环) ===== */
|
||
export function Marquee(props: { items: string[]; class?: string }) {
|
||
const row = (aria: boolean) => (
|
||
<div class="flex shrink-0 items-center" aria-hidden={aria || undefined}>
|
||
<For each={props.items}>
|
||
{(t) => (
|
||
<>
|
||
<span class="px-5 font-mono text-[11px] tracking-[0.22em] uppercase text-muted-foreground sm:px-7">
|
||
{t}
|
||
</span>
|
||
<span class="size-1.5 shrink-0 rotate-45 bg-acid-lime" />
|
||
</>
|
||
)}
|
||
</For>
|
||
</div>
|
||
)
|
||
return (
|
||
<div class={`marquee border-y border-line/80 py-3 ${props.class ?? ''}`}>
|
||
<div class="marquee-track">
|
||
{row(false)}
|
||
{row(true)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|