live-sfu-demo/web/src/components/ui.tsx

409 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}