Fishies 0.8.1

This commit is contained in:
CRTkafa 2026-10-01 12:41:30 +07:00
commit 5821ce9f4d
76 changed files with 2198 additions and 0 deletions

203
src/kit.tsx Normal file
View file

@ -0,0 +1,203 @@
'use client'
import { useId, useState, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode } from 'react'
import FishiesImg from './FishiesImg'
import { ICON_SIZES, iconByName, iconUrl, type IconSize, type SystemIconName } from './icons'
import './kit.css'
/* fishies-ui components. Each export's first doc sentence is its Handbook entry (the build reads
them), so a component is described once, here, next to the code that draws it. */
const cx = (...names: (string | false | null | undefined)[]) => names.filter(Boolean).join(' ')
const px = (size: IconSize | number) => typeof size === 'number' ? size : ICON_SIZES[size]
/** A painted icon from the system set, by name, at one of the set's sizes. */
export function SystemIcon({ name, size = 'md', fallback, className }: { name: SystemIconName; size?: IconSize | number; fallback?: ReactNode; className?: string }) {
const [failed, setFailed] = useState(false)
const icon = iconByName(name)
const side = px(size)
if (!icon || failed) return fallback ? <>{fallback}</> : null
return <span className={cx('fx-icon', className)} style={{ width: side, height: side }} aria-hidden="true">
<FishiesImg src={iconUrl(icon)} width={side} height={side} alt="" draggable={false} decoding="async" onError={() => setFailed(true)}/>
</span>
}
export type ButtonTone = 'glass' | 'gold' | 'ghost' | 'danger'
type ButtonOwn = { tone?: ButtonTone; size?: 'sm' | 'md' | 'lg'; icon?: SystemIconName; children?: ReactNode }
/** A plump toy button: glass for everyday actions, gold for the one thing to press, ghost for a quiet third choice, danger for what cannot be undone. */
export function Button({ tone = 'glass', size = 'md', icon, children, className, type = 'button', ...rest }: ButtonOwn & ButtonHTMLAttributes<HTMLButtonElement>) {
return <button type={type} className={cx('fx-button', `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
{icon && <SystemIcon name={icon} size={size === 'lg' ? 30 : size === 'sm' ? 20 : 26}/>}{children}
</button>
}
/** The same button as a link, for an action that goes somewhere. */
export function ButtonLink({ tone = 'glass', size = 'md', icon, children, className, ...rest }: ButtonOwn & AnchorHTMLAttributes<HTMLAnchorElement>) {
return <a className={cx('fx-button', `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
{icon && <SystemIcon name={icon} size={size === 'lg' ? 30 : size === 'sm' ? 20 : 26}/>}{children}
</a>
}
/** A round toy button for window controls, the video player and close; `label` is what it says to a screen reader. */
export function RoundButton({ label, tone = 'navy', size = 'md', icon, children, className, type = 'button', ...rest }: { label: string; tone?: 'navy' | 'coral' | 'gold'; size?: 'sm' | 'md' | 'lg'; icon?: SystemIconName; children?: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
return <button type={type} aria-label={label} title={label} className={cx('fx-round', tone !== 'navy' && `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
{icon ? <SystemIcon name={icon} size={size === 'lg' ? 34 : 26}/> : children}
</button>
}
/** A small reading or choice: a dot of colour for a kind, an icon, or gold when chosen. */
export function Chip({ tone, icon, selected, onClick, children, className }: { tone?: string; icon?: SystemIconName; selected?: boolean; onClick?: () => void; children: ReactNode; className?: string }) {
const style = tone ? { '--tone': tone } as CSSProperties : undefined
const inner = <>{tone && <i aria-hidden="true"/>}{icon && <SystemIcon name={icon} size={18}/>}{children}</>
return onClick
? <button type="button" className={cx('fx-chip', className)} style={style} aria-pressed={selected} onClick={onClick}>{inner}</button>
: <span className={cx('fx-chip', selected && 'is-on', className)} style={style}>{inner}</span>
}
/** A coral count bubble that sits on a tile, a dock item or a bell; a dot when the number does not matter. */
export function Badge({ count, label }: { count?: number; label?: string }) {
return count === undefined
? <span className="fx-badge is-dot" role="status" aria-label={label}/>
: <span className="fx-badge" role="status" aria-label={label}>{count > 99 ? '99+' : count}</span>
}
/** A gold-rimmed capsule for a small upper-case word beside a name (Beta, New, Admin). */
export function Tag({ children }: { children: ReactNode }) {
return <span className="fx-tag">{children}</span>
}
/** A glass card: an optional eyebrow, a title and a few lines. */
export function Card({ eyebrow, title, children, className }: { eyebrow?: string; title?: ReactNode; children?: ReactNode; className?: string }) {
return <article className={cx('fx-card', className)}>
{eyebrow && <span className="fx-eyebrow">{eyebrow}</span>}
{title && <h3>{title}</h3>}
{children}
</article>
}
/** A gold-rimmed glass panel with a title row (icon, title, actions) and a body. */
export function Panel({ title, icon, actions, children, className, style }: { title: ReactNode; icon?: SystemIconName; actions?: ReactNode; children?: ReactNode; className?: string; style?: CSSProperties }) {
return <section className={cx('fx-panel', className)} style={style}>
<header className="fx-panel-head">{icon && <SystemIcon name={icon} size={30}/>}<h2>{title}</h2>{actions}</header>
<div className="fx-panel-body">{children}</div>
</section>
}
/** A Keeper OS window frame: a title bar with round minimise and close buttons over a darker well. */
export function Window({ title, icon, onClose, onMinimize, closeLabel = 'Close', minimizeLabel = 'Minimise', children, className, style }: {
title: ReactNode; icon?: SystemIconName; onClose?: () => void; onMinimize?: () => void; closeLabel?: string; minimizeLabel?: string
children?: ReactNode; className?: string; style?: CSSProperties
}) {
return <section className={cx('fx-window', className)} style={style} role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
<header className="fx-window-bar">
{icon && <SystemIcon name={icon} size={26}/>}
<h2>{title}</h2>
<span className="fx-window-buttons">
{onMinimize && <RoundButton label={minimizeLabel} size="sm" onClick={onMinimize}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h9" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
{onClose && <RoundButton label={closeLabel} size="sm" tone="coral" onClick={onClose}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
</span>
</header>
<div className="fx-window-body">{children}</div>
</section>
}
export type TabItem = { id: string; label: string; icon?: SystemIconName }
/** A row of pill tabs in a sunken track; the chosen one is gold. */
export function Tabs({ items, value, onChange, label }: { items: readonly TabItem[]; value: string; onChange: (id: string) => void; label: string }) {
const move = (from: number, step: number) => onChange(items[(from + step + items.length) % items.length]!.id)
return <div className="fx-tabs" role="tablist" aria-label={label}>
{items.map((item, index) => <button key={item.id} type="button" role="tab" className="fx-tab" aria-selected={item.id === value} tabIndex={item.id === value ? 0 : -1}
onClick={() => onChange(item.id)} onKeyDown={event => { if (event.key === 'ArrowRight') move(index, 1); if (event.key === 'ArrowLeft') move(index, -1) }}>
{item.icon && <SystemIcon name={item.icon} size={22}/>}{item.label}
</button>)}
</div>
}
/** A list of rows; each row has an icon, a title, a detail line and a value at its end. */
export function List({ children, label }: { children: ReactNode; label?: string }) {
return <ul className="fx-list" aria-label={label}>{children}</ul>
}
/** One row of a list; a button when it does something, a link when it goes somewhere. */
export function ListRow({ icon, title, detail, end, onClick, href, selected }: { icon?: SystemIconName; title: ReactNode; detail?: ReactNode; end?: ReactNode; onClick?: () => void; href?: string; selected?: boolean }) {
const inner = <>{icon && <SystemIcon name={icon} size={36}/>}<span className="fx-row-text"><b>{title}</b>{detail && <small>{detail}</small>}</span>{end !== undefined && <span className="fx-row-end">{end}</span>}</>
return <li>{href ? <a className={cx('fx-row', selected && 'is-on')} href={href}>{inner}</a>
: onClick ? <button type="button" className={cx('fx-row', selected && 'is-on')} onClick={onClick} aria-pressed={selected}>{inner}</button>
: <div className={cx('fx-row', selected && 'is-on')}>{inner}</div>}</li>
}
/** A labelled text field in a sunken pill, with an optional hint under it. */
export function Field({ label, hint, className, ...input }: { label: string; hint?: string } & InputHTMLAttributes<HTMLInputElement>) {
const id = useId()
return <label className={cx('fx-field', className)} htmlFor={id}>
<span>{label}</span>
<input id={id} className="fx-input" aria-describedby={hint ? `${id}-hint` : undefined} {...input}/>
{hint && <small id={`${id}-hint`}>{hint}</small>}
</label>
}
/** An on and off switch with a pearl knob; gold when on. */
export function Toggle({ label, checked, onChange, disabled }: { label: ReactNode; checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean }) {
return <label className="fx-toggle">
<input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={event => onChange(event.target.checked)}/>
<i aria-hidden="true"/>{label}
</label>
}
export type TooltipRow = { label: ReactNode; value: ReactNode }
/** A designed tooltip, never plain text: an icon and title, a line, and labelled values; it opens on hover and on keyboard focus. */
export function Tooltip({ title, icon, text, rows, children, open }: { title: ReactNode; icon?: SystemIconName; text?: ReactNode; rows?: readonly TooltipRow[]; children: ReactNode; open?: boolean }) {
const id = useId()
return <span className="fx-tip" aria-describedby={id}>
{children}
<span className={cx('fx-tooltip', open && 'is-open')} role="tooltip" id={id}>
<span className="fx-tooltip-head">{icon && <SystemIcon name={icon} size={26}/>}{title}</span>
{text && <p>{text}</p>}
{rows && rows.length > 0 && <dl>{rows.map((row, index) => <div key={index}><dt>{row.label}</dt><dd>{row.value}</dd></div>)}</dl>}
</span>
</span>
}
/** A notice that slides in at the edge: an icon in a coloured bubble, a title, a line and a close. */
export function Toast({ title, children, icon, tone, onClose, closeLabel = 'Close' }: { title: ReactNode; children?: ReactNode; icon?: SystemIconName; tone?: string; onClose?: () => void; closeLabel?: string }) {
return <div className="fx-toast" role="status" style={tone ? { '--tone': tone } as CSSProperties : undefined}>
{icon && <span className="fx-toast-art"><SystemIcon name={icon} size={32}/></span>}
<span className="fx-toast-text"><b>{title}</b>{children && <span>{children}</span>}</span>
{onClose && <RoundButton label={closeLabel} size="sm" onClick={onClose}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
</div>
}
/** A number a keeper reads at a glance: an icon, the value in gold with its unit, and what it counts. */
export function Stat({ icon, value, unit, label }: { icon?: SystemIconName; value: ReactNode; unit?: string; label: ReactNode }) {
return <div className="fx-stat">
{icon && <SystemIcon name={icon} size={38}/>}
<span><b>{value}{unit && <small>{unit}</small>}</b><span>{label}</span></span>
</div>
}
/** A desktop tile: a plump glass square in its own hue holding an icon, a title under it, a badge and an open mark. */
export function DockTile({ icon, title, hue, badge, open, onClick }: { icon: SystemIconName; title: string; hue: number; badge?: number; open?: boolean; onClick?: () => void }) {
return <button type="button" className={cx('fx-tile', open && 'is-open')} onClick={onClick} style={{ '--hue': hue } as CSSProperties}>
<span className="fx-tile-glass"><SystemIcon name={icon} size={72}/>{badge !== undefined && badge > 0 && <Badge count={badge}/>}</span>
<span className="fx-tile-title">{title}</span>
</button>
}
/** A dock: a gold-rimmed glass shelf of icons; the app in front wears a gold bar, open ones a dot. */
export function Dock({ children, label }: { children: ReactNode; label: string }) {
return <nav className="fx-dock" aria-label={label}>{children}</nav>
}
/** One icon on a dock. */
export function DockItem({ icon, label, front, open, badge, onClick }: { icon: SystemIconName; label: string; front?: boolean; open?: boolean; badge?: number; onClick?: () => void }) {
return <button type="button" className={cx('fx-dock-item', open && 'is-open', front && 'is-front')} aria-label={label} title={label} onClick={onClick}>
<SystemIcon name={icon} size="lg"/>{badge !== undefined && badge > 0 && <Badge count={badge}/>}<i aria-hidden="true"/>
</button>
}
export { Spinner, Progress } from './loader'
export { default as Select } from './Select'
export { default as Image } from './FishiesImg'