190 lines
12 KiB
TypeScript
190 lines
12 KiB
TypeScript
'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'
|
|
|
|
const cx = (...names: (string | false | null | undefined)[]) => names.filter(Boolean).join(' ')
|
|
const px = (size: IconSize | number) => typeof size === 'number' ? size : ICON_SIZES[size]
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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 }
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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'
|