mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
876 lines
40 KiB
TypeScript
876 lines
40 KiB
TypeScript
'use client';
|
||
|
||
// The plugin UI kit: the app's own controls, for plugin iframes. Plugins get
|
||
// it as `require('@plugin-host/ui')`; the runtime hands over this module, so
|
||
// a plugin bundle carries none of it and always matches the app it runs in.
|
||
//
|
||
// Every component is built from the same Tailwind classes as its
|
||
// counterpart in the app (named in each comment), and
|
||
// ui-kit.test.tsx checks that those classes are still the app's. The CSS
|
||
// comes from app/(sandbox)/plugin-kit.css, which scans this folder only, so
|
||
// classes must be written out in full here (no string building).
|
||
//
|
||
// Each component's root carries `bk`, which scopes the CSS reset to kit
|
||
// elements: a plugin's own markup keeps the browser defaults it was written
|
||
// against.
|
||
|
||
import * as React from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
import { Button as AppButton } from '@/components/ui/button';
|
||
import { Input as AppInput } from '@/components/ui/input';
|
||
import { getFileIconByName, getFolderIcon } from '@/components/files/file-icons';
|
||
import { iconForName } from '@/components/icons/named';
|
||
import { Check, ChevronRight, Loader2, Puzzle, Search, X } from '@/components/icons';
|
||
|
||
type Children = { children?: React.ReactNode };
|
||
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'primary';
|
||
|
||
// ─── Icons ─────────────────────────────────────────────────────
|
||
|
||
/** An app icon by name (the names api.ui.setActions takes). Unknown names show a puzzle piece. */
|
||
export function Icon({ name, size = 'md', className }: { name?: string; size?: 'xs' | 'sm' | 'md' | 'lg'; className?: string }) {
|
||
const Glyph = (name ? iconForName(name) : null) ?? Puzzle;
|
||
const sizeClass = { xs: 'w-3 h-3', sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-5 h-5' }[size];
|
||
return <Glyph className={cn('bk flex-shrink-0', sizeClass, className)} aria-hidden="true" />;
|
||
}
|
||
|
||
/** The Files app's icon for a file name (by extension), or its folder icon. */
|
||
export function FileIcon({ name = '', folder = false, size = 'sm' }: { name?: string; folder?: boolean; size?: 'sm' | 'md' | 'lg' }) {
|
||
return <span className="bk inline-flex flex-shrink-0" aria-hidden="true">{folder ? getFolderIcon(size) : getFileIconByName(name, size)}</span>;
|
||
}
|
||
|
||
/** Loader2 with animate-spin, as everywhere in the app. */
|
||
export function Spinner({ size = 'md', label }: { size?: 'sm' | 'md' | 'lg'; label?: string }) {
|
||
const sizeClass = { sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-6 h-6' }[size];
|
||
return (
|
||
<span className="bk inline-flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||
<Loader2 className={cn(sizeClass, 'animate-spin')} aria-hidden="true" />
|
||
{label ? <span>{label}</span> : <span className="sr-only">…</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** A spinner in the middle of a page or panel (plugin-page-app's loading state). */
|
||
export function Loading({ label }: { label?: string }) {
|
||
return (
|
||
<div className="bk flex items-center justify-center py-12">
|
||
<Spinner size="lg" label={label} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Buttons ───────────────────────────────────────────────────
|
||
|
||
export interface KitButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||
/** primary = the app's default button, secondary = outline. */
|
||
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
|
||
size?: 'sm' | 'md' | 'icon';
|
||
icon?: string;
|
||
/** Shows a spinner and disables the button. */
|
||
busy?: boolean;
|
||
children?: React.ReactNode;
|
||
}
|
||
|
||
const BUTTON_VARIANT = { primary: 'default', secondary: 'outline', ghost: 'ghost', destructive: 'destructive' } as const;
|
||
|
||
/** components/ui/button.tsx itself. */
|
||
export const Button = React.forwardRef<HTMLButtonElement, KitButtonProps>(function Button(
|
||
{ variant = 'primary', size = 'md', icon, busy = false, disabled, className, children, type = 'button', ...rest },
|
||
ref,
|
||
) {
|
||
return (
|
||
<AppButton
|
||
ref={ref}
|
||
type={type}
|
||
variant={BUTTON_VARIANT[variant] ?? 'default'}
|
||
size={size}
|
||
disabled={disabled || busy}
|
||
aria-busy={busy || undefined}
|
||
className={cn('bk gap-1.5', size !== 'icon' && size !== 'sm' && 'text-sm', className)}
|
||
{...rest}
|
||
>
|
||
{busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : icon ? <Icon name={icon} /> : null}
|
||
{children}
|
||
</AppButton>
|
||
);
|
||
});
|
||
|
||
/** A text link styled like the app's inline links ("Add condition"). */
|
||
export function LinkButton({ icon, children, className, ...rest }: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: string }) {
|
||
return (
|
||
<button type="button" className={cn('bk inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50 disabled:pointer-events-none', className)} {...rest}>
|
||
{icon && <Icon name={icon} size="sm" />}
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** An external link: opens in a new tab. */
|
||
export function Link({ href, children, className }: { href: string; className?: string } & Children) {
|
||
return (
|
||
<a href={href} target="_blank" rel="noopener noreferrer" className={cn('bk text-primary hover:underline underline-offset-2', className)}>
|
||
{children}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
// ─── Form controls ─────────────────────────────────────────────
|
||
|
||
/** components/ui/input.tsx itself. */
|
||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function Input({ className, ...rest }, ref) {
|
||
return <AppInput ref={ref} className={cn('bk', className)} {...rest} />;
|
||
});
|
||
|
||
/** The textarea of the identity form (Input without the fixed height). */
|
||
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea({ className, ...rest }, ref) {
|
||
return (
|
||
<textarea
|
||
ref={ref}
|
||
className={cn('bk flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50', className)}
|
||
{...rest}
|
||
/>
|
||
);
|
||
});
|
||
|
||
export interface KitSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||
options?: { value: string; label: string; disabled?: boolean }[];
|
||
/** Called with the new value. */
|
||
onChange?: (value: string, event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||
/** Stretch to the width of its container (forms); settings rows keep it narrow. */
|
||
block?: boolean;
|
||
}
|
||
|
||
/** The settings select (settings-section.tsx). */
|
||
export function Select({ options, children, onChange, disabled, block = false, className, ...rest }: KitSelectProps) {
|
||
return (
|
||
<select
|
||
className={cn(
|
||
'bk px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150',
|
||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:border-muted-foreground',
|
||
block && 'w-full',
|
||
className,
|
||
)}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange?.(e.target.value, e)}
|
||
{...rest}
|
||
>
|
||
{options?.map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
|
||
{children}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
export interface KitCheckboxProps {
|
||
checked: boolean;
|
||
onChange?: (checked: boolean) => void;
|
||
label?: React.ReactNode;
|
||
/** Name for screen readers when there is no visible label. */
|
||
ariaLabel?: string;
|
||
hint?: React.ReactNode;
|
||
disabled?: boolean;
|
||
id?: string;
|
||
}
|
||
|
||
/** The composer's check items and the Files app's select box: a drawn box, not the browser's. */
|
||
export function Checkbox({ checked, onChange, label, hint, disabled = false, id, ariaLabel }: KitCheckboxProps) {
|
||
const autoId = React.useId();
|
||
const boxId = id ?? autoId;
|
||
const box = (
|
||
<button
|
||
id={boxId}
|
||
type="button"
|
||
role="checkbox"
|
||
aria-checked={checked}
|
||
aria-label={label ? undefined : ariaLabel}
|
||
disabled={disabled}
|
||
onClick={() => onChange?.(!checked)}
|
||
className={cn(
|
||
'bk mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50',
|
||
checked ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/50 bg-background',
|
||
)}
|
||
>
|
||
{checked && <Check className="w-3 h-3" strokeWidth={3} aria-hidden="true" />}
|
||
</button>
|
||
);
|
||
if (!label) return box;
|
||
return (
|
||
<div className={cn('bk flex items-start gap-2', disabled && 'opacity-60')}>
|
||
{box}
|
||
<div className="min-w-0">
|
||
<label htmlFor={boxId} className="text-sm text-foreground cursor-pointer">{label}</label>
|
||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** ToggleSwitch from settings-section.tsx. */
|
||
export function Switch({ checked, onChange, disabled = false, label, id }: { checked: boolean; onChange?: (checked: boolean) => void; disabled?: boolean; label?: string; id?: string }) {
|
||
return (
|
||
<button
|
||
id={id}
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
onClick={() => onChange?.(!checked)}
|
||
className={cn(
|
||
'bk relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
checked ? 'bg-primary' : 'bg-muted',
|
||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150',
|
||
checked ? 'ltr:translate-x-6 rtl:-translate-x-6' : 'ltr:translate-x-1 rtl:-translate-x-1',
|
||
)}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** RadioGroup from settings-section.tsx: a row of segments. */
|
||
export function Segmented({ value, options, onChange, label }: { value: string; options: { value: string; label: string }[]; onChange?: (value: string) => void; label?: string }) {
|
||
return (
|
||
<div className="bk flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>
|
||
{options.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={o.value === value}
|
||
onClick={() => onChange?.(o.value)}
|
||
className={cn(
|
||
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
o.value === value ? 'bg-primary text-primary-foreground font-medium' : 'bg-muted hover:bg-accent text-foreground',
|
||
)}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A form field: label above, hint and error below (identity form). The
|
||
* control inside gets the label's id when it has none.
|
||
*/
|
||
export function Field({ label, hint, error, required = false, children }: { label: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; required?: boolean } & Children) {
|
||
const autoId = React.useId();
|
||
const only = React.Children.count(children) === 1 && React.isValidElement(children) ? children as React.ReactElement<{ id?: string }> : null;
|
||
const controlId = only?.props.id ?? autoId;
|
||
return (
|
||
<div className="bk">
|
||
<label htmlFor={only ? controlId : undefined} className="text-sm font-medium mb-1 block text-foreground">
|
||
{label}
|
||
{required && <span className="text-destructive"> *</span>}
|
||
</label>
|
||
{only && !only.props.id ? React.cloneElement(only, { id: controlId }) : children}
|
||
{hint && !error && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||
{error && <p className="text-sm text-destructive mt-1" role="alert">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A search box like the Files toolbar's. */
|
||
export function SearchInput({ value, onChange, placeholder, label, className }: { value: string; onChange: (value: string) => void; placeholder?: string; label?: string; className?: string }) {
|
||
return (
|
||
<div className={cn('bk relative w-56 max-w-full', className)}>
|
||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" aria-hidden="true" />
|
||
<input
|
||
type="search"
|
||
value={value}
|
||
placeholder={placeholder}
|
||
aria-label={label ?? placeholder}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="w-full h-8 rounded-md border border-input bg-background ps-8 pe-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-search-cancel-button]:hidden"
|
||
/>
|
||
{value && (
|
||
<button type="button" onClick={() => onChange('')} aria-label="×" className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground">
|
||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Layout ────────────────────────────────────────────────────
|
||
|
||
const GAP = { 0: 'gap-0', 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', 4: 'gap-4', 6: 'gap-6' } as const;
|
||
type Gap = keyof typeof GAP;
|
||
|
||
/** Children one below the other. */
|
||
export function Stack({ gap = 3, children, className }: { gap?: Gap; className?: string } & Children) {
|
||
return <div className={cn('bk flex flex-col min-w-0', GAP[gap], className)}>{children}</div>;
|
||
}
|
||
|
||
/** Children side by side. */
|
||
export function Row({ gap = 2, wrap = true, justify = 'start', align = 'center', grow = false, fill = false, children, className }: {
|
||
gap?: Gap; wrap?: boolean; justify?: 'start' | 'end' | 'between'; align?: 'start' | 'center' | 'baseline'; grow?: boolean;
|
||
/** Children share the width equally, e.g. two Fields side by side; they wrap below 12rem each. */
|
||
fill?: boolean; className?: string;
|
||
} & Children) {
|
||
return (
|
||
<div className={cn(
|
||
'bk flex min-w-0', GAP[gap], wrap && 'flex-wrap', grow && 'flex-1', fill && '[&>*]:flex-1 [&>*]:min-w-[12rem]',
|
||
{ start: 'justify-start', end: 'justify-end', between: 'justify-between' }[justify],
|
||
{ start: 'items-start', center: 'items-center', baseline: 'items-baseline' }[align],
|
||
className,
|
||
)}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Body text. */
|
||
export function Text({ tone = 'default', size = 'sm', weight = 'normal', truncate = false, children, className }: {
|
||
tone?: 'default' | 'muted' | 'danger' | 'success'; size?: 'xs' | 'sm' | 'base'; weight?: 'normal' | 'medium' | 'semibold'; truncate?: boolean; className?: string;
|
||
} & Children) {
|
||
return (
|
||
<p className={cn(
|
||
'bk break-words',
|
||
{ default: 'text-foreground', muted: 'text-muted-foreground', danger: 'text-destructive', success: 'text-success' }[tone],
|
||
{ xs: 'text-xs', sm: 'text-sm', base: 'text-base' }[size],
|
||
{ normal: '', medium: 'font-medium', semibold: 'font-semibold' }[weight],
|
||
truncate && 'truncate',
|
||
className,
|
||
)}>
|
||
{children}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
/** A heading inside a section (settings subheading). */
|
||
export function Heading({ children, level = 3 }: { level?: 2 | 3 | 4 } & Children) {
|
||
const Tag = `h${level}` as 'h2' | 'h3' | 'h4';
|
||
return <Tag className={cn('bk text-foreground', level === 2 ? 'text-lg font-semibold' : level === 3 ? 'text-base font-medium' : 'text-sm font-medium')}>{children}</Tag>;
|
||
}
|
||
|
||
export function Divider() {
|
||
return <div className="bk h-px bg-border" role="separator" />;
|
||
}
|
||
|
||
/**
|
||
* A plugin's full page (app-page slot): the app's content width and
|
||
* padding, with an optional header. Pages that are a tool, like a file
|
||
* browser, pass `flush` and put a Toolbar first instead.
|
||
*/
|
||
export function Page({ flush = false, wide = false, children }: { flush?: boolean; wide?: boolean } & Children) {
|
||
if (flush) return <div className="bk flex flex-col min-h-full text-foreground">{children}</div>;
|
||
return <div className={cn('bk mx-auto p-4 md:p-6 space-y-6 text-foreground', wide ? 'max-w-6xl' : 'max-w-4xl')}>{children}</div>;
|
||
}
|
||
|
||
/** The admin pages' header: title, subtitle, actions on the right. */
|
||
export function PageHeader({ title, description, actions, icon }: { title: React.ReactNode; description?: React.ReactNode; actions?: React.ReactNode; icon?: string }) {
|
||
return (
|
||
<div className="bk flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-foreground">
|
||
{icon && <Icon name={icon} size="lg" className="text-muted-foreground" />}
|
||
<span className="min-w-0 truncate">{title}</span>
|
||
</h1>
|
||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
||
</div>
|
||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** SettingsSection: a titled group of settings. */
|
||
export function Section({ title, description, children }: { title?: React.ReactNode; description?: React.ReactNode } & Children) {
|
||
return (
|
||
<section className="bk space-y-4">
|
||
{(title || description) && (
|
||
<div>
|
||
{title && <h3 className="text-lg font-medium text-foreground">{title}</h3>}
|
||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
||
</div>
|
||
)}
|
||
<div className="space-y-4">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** SettingItem: label and description on the left, the control on the right. */
|
||
export function SettingRow({ label, description, children, htmlFor }: { label: React.ReactNode; description?: React.ReactNode; htmlFor?: string } & Children) {
|
||
return (
|
||
<div className="bk flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0">
|
||
<div className="flex-1 min-w-0 sm:pe-4">
|
||
<label htmlFor={htmlFor} className="text-sm font-medium text-foreground">{label}</label>
|
||
{description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
|
||
</div>
|
||
<div className="flex-shrink-0">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The admin section card: a bordered box with a tinted head. */
|
||
export function Card({ title, actions, children, padded = true }: { title?: React.ReactNode; actions?: React.ReactNode; padded?: boolean } & Children) {
|
||
return (
|
||
<div className="bk border border-border rounded-lg overflow-hidden">
|
||
{(title || actions) && (
|
||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border bg-muted/30">
|
||
<h2 className="text-sm font-medium text-foreground min-w-0 truncate">{title}</h2>
|
||
{actions && <div className="flex items-center gap-1">{actions}</div>}
|
||
</div>
|
||
)}
|
||
<div className={cn(padded && 'p-4')}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Tabs ──────────────────────────────────────────────────────
|
||
|
||
/** The tab bar of the app's dialogs (iCal import). */
|
||
export function Tabs({ value, tabs, onChange, label }: { value: string; tabs: { id: string; label: string; icon?: string; count?: number }[]; onChange: (id: string) => void; label?: string }) {
|
||
return (
|
||
<div className="bk flex border-b border-border overflow-x-auto" role="tablist" aria-label={label}>
|
||
{tabs.map((tab) => {
|
||
const active = tab.id === value;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => onChange(tab.id)}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||
active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{tab.icon && <Icon name={tab.icon} />}
|
||
<span>{tab.label}</span>
|
||
{tab.count !== undefined && <span className="text-xs font-semibold tabular-nums">{tab.count}</span>}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Toolbar (Files app) ───────────────────────────────────────
|
||
|
||
/** The Files app's toolbar row. */
|
||
export function Toolbar({ children }: Children) {
|
||
return (
|
||
<div className="bk flex items-center gap-1 px-3 border-b border-border min-w-0 flex-shrink-0" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** ToolButton from the Files header: ghost, 32 px high, icon-only when compact. */
|
||
export function ToolbarButton({ icon, label, compact = false, active = false, danger = false, busy = false, disabled, onClick }: {
|
||
icon?: string; label: string; compact?: boolean; active?: boolean; danger?: boolean; busy?: boolean; disabled?: boolean; onClick?: () => void;
|
||
}) {
|
||
return (
|
||
<AppButton
|
||
type="button"
|
||
variant="ghost"
|
||
size={compact ? 'icon' : 'sm'}
|
||
title={compact ? label : undefined}
|
||
aria-label={compact ? label : undefined}
|
||
aria-pressed={active || undefined}
|
||
disabled={disabled || busy}
|
||
onClick={onClick}
|
||
className={cn(
|
||
'bk h-8 shrink-0',
|
||
compact ? 'w-8' : 'gap-1.5 px-2.5',
|
||
active && 'bg-accent text-accent-foreground',
|
||
danger && 'text-destructive hover:text-destructive hover:bg-destructive/10',
|
||
)}
|
||
>
|
||
{busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : icon ? <Icon name={icon} /> : null}
|
||
{!compact && <span>{label}</span>}
|
||
</AppButton>
|
||
);
|
||
}
|
||
|
||
/** Takes the free space in a Toolbar. */
|
||
export function ToolbarSpacer() {
|
||
return <div className="bk flex-1 min-w-0" />;
|
||
}
|
||
|
||
/** The Files app's breadcrumbs. */
|
||
export function Breadcrumbs({ items, onSelect, label }: { items: { id: string; label: string }[]; onSelect: (id: string) => void; label?: string }) {
|
||
return (
|
||
<nav className="bk flex-1 min-w-0" aria-label={label}>
|
||
<ol className="flex items-center text-sm min-w-0">
|
||
{items.map((item, i) => {
|
||
const last = i === items.length - 1;
|
||
return (
|
||
<li key={item.id} className="flex items-center min-w-0 shrink-0 last:shrink">
|
||
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden="true" />}
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelect(item.id)}
|
||
aria-current={last ? 'page' : undefined}
|
||
className={cn('px-1.5 py-1 rounded-md transition-colors truncate max-w-[16rem]', last ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted')}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
// ─── Lists (Files app rows) ────────────────────────────────────
|
||
|
||
/** A list like the Files app's: optional column header, rows below. */
|
||
export function List({ children, label, columns }: { label?: string; columns?: { label: string; align?: 'start' | 'end'; hideNarrow?: boolean }[] } & Children) {
|
||
return (
|
||
<div className="bk text-sm" role="list" aria-label={label}>
|
||
{columns && (
|
||
<div className="flex items-center gap-x-3 sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 h-9 text-xs font-medium text-muted-foreground" aria-hidden="true">
|
||
{columns.map((c, i) => (
|
||
<span key={i} className={cn(i === 0 ? 'flex-1 min-w-0 ps-8' : 'w-24 flex-shrink-0', c.align === 'end' && 'text-end', c.hideNarrow && 'hidden sm:block')}>{c.label}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export interface KitListRowProps {
|
||
title: React.ReactNode;
|
||
/** A line under the title. */
|
||
subtitle?: React.ReactNode;
|
||
/** Short right-hand columns (size, date). */
|
||
meta?: React.ReactNode[];
|
||
/** FileIcon or Icon; or a file name to use the Files app's icon for it. */
|
||
icon?: React.ReactNode;
|
||
folder?: boolean;
|
||
selected?: boolean;
|
||
/** Shows a select box in front. */
|
||
checked?: boolean;
|
||
onCheck?: (checked: boolean) => void;
|
||
/** Name of the select box for screen readers, e.g. "Select report.pdf". */
|
||
checkLabel?: string;
|
||
onClick?: () => void;
|
||
onDoubleClick?: () => void;
|
||
/** Buttons at the end of the row. */
|
||
actions?: React.ReactNode;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
/** A Files app row: 40 px, icon, name, muted meta columns, hover and selected states. */
|
||
export function ListRow({ title, subtitle, meta, icon, folder = false, selected = false, checked, onCheck, checkLabel, onClick, onDoubleClick, actions, disabled = false }: KitListRowProps) {
|
||
const clickable = !!onClick && !disabled;
|
||
return (
|
||
<div
|
||
role="listitem"
|
||
aria-selected={selected || undefined}
|
||
aria-disabled={disabled || undefined}
|
||
tabIndex={clickable ? 0 : undefined}
|
||
onClick={clickable ? onClick : undefined}
|
||
onDoubleClick={disabled ? undefined : onDoubleClick}
|
||
onKeyDown={clickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } } : undefined}
|
||
className={cn(
|
||
'bk group flex items-center gap-x-3 px-4 border-b border-border/60 select-none transition-colors outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring/60',
|
||
subtitle ? 'min-h-[52px] py-1.5' : 'h-10',
|
||
selected ? 'bg-primary/10' : clickable && 'hover:bg-muted/60',
|
||
clickable ? 'cursor-pointer' : 'cursor-default',
|
||
disabled && 'opacity-50',
|
||
)}
|
||
>
|
||
{checked !== undefined && (
|
||
<span onClick={(e) => e.stopPropagation()} className="flex">
|
||
<Checkbox checked={checked} onChange={onCheck} disabled={disabled} ariaLabel={checkLabel ?? (typeof title === 'string' ? title : undefined)} />
|
||
</span>
|
||
)}
|
||
<span className="flex items-center gap-3 min-w-0 flex-1">
|
||
{typeof icon === 'string' ? <FileIcon name={icon} folder={folder} /> : icon ?? (folder ? <FileIcon folder /> : null)}
|
||
<span className="min-w-0 flex-1">
|
||
<span className={cn('block truncate text-start', folder && 'font-medium')}>{title}</span>
|
||
{subtitle && <span className="block truncate text-xs text-muted-foreground">{subtitle}</span>}
|
||
</span>
|
||
</span>
|
||
{meta?.map((m, i) => (
|
||
<span key={i} className={cn('flex-shrink-0 text-muted-foreground tabular-nums truncate text-end w-24', i > 0 && 'hidden sm:block')}>{m}</span>
|
||
))}
|
||
{actions && <span className="flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>{actions}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A row's icon button (Files row actions). */
|
||
export function RowAction({ icon, label, onClick, danger = false }: { icon: string; label: string; onClick?: () => void; danger?: boolean }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={label}
|
||
aria-label={label}
|
||
className={cn('bk p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', danger && 'hover:text-destructive')}
|
||
>
|
||
<Icon name={icon} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ─── Status ────────────────────────────────────────────────────
|
||
|
||
const BADGE_TONE: Record<Tone, string> = {
|
||
neutral: 'bg-muted text-muted-foreground',
|
||
info: 'bg-info/15 text-info',
|
||
success: 'bg-success/15 text-success',
|
||
warning: 'bg-warning/15 text-warning',
|
||
danger: 'bg-destructive/15 text-destructive',
|
||
primary: 'bg-primary/10 text-primary',
|
||
};
|
||
|
||
/** The status pills (calendar invitation, identity "Default"). */
|
||
export function Badge({ tone = 'neutral', children }: { tone?: Tone } & Children) {
|
||
return <span className={cn('bk inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap', BADGE_TONE[tone])}>{children}</span>;
|
||
}
|
||
|
||
/** The reader's attachment chip: icon, label, muted detail. Clickable when onClick is set. */
|
||
export function Chip({ icon, label, detail, onClick, onRemove, removeLabel, title }: {
|
||
icon?: string; label: React.ReactNode; detail?: React.ReactNode; onClick?: () => void; onRemove?: () => void; removeLabel?: string; title?: string;
|
||
}) {
|
||
const body = (
|
||
<>
|
||
{icon && <Icon name={icon} className="text-muted-foreground" />}
|
||
<span className="text-sm text-foreground truncate max-w-[200px]">{label}</span>
|
||
{detail && <span className="text-xs text-muted-foreground flex-shrink-0">{detail}</span>}
|
||
</>
|
||
);
|
||
const box = 'bk inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 flex-shrink-0 max-w-full';
|
||
if (onClick && !onRemove) {
|
||
return <button type="button" title={title} onClick={onClick} className={cn(box, 'hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}>{body}</button>;
|
||
}
|
||
return (
|
||
<span className={box} title={title}>
|
||
{body}
|
||
{onRemove && (
|
||
<button type="button" onClick={onRemove} aria-label={removeLabel ?? '×'} className="p-0.5 -me-1 rounded hover:bg-accent transition-colors">
|
||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const INFO_TONE: Record<Tone, string> = {
|
||
neutral: 'bg-muted text-muted-foreground',
|
||
info: 'bg-info/15 text-info',
|
||
success: 'bg-success/15 text-success',
|
||
warning: 'bg-warning/15 text-warning',
|
||
danger: 'bg-destructive/15 text-destructive',
|
||
primary: 'bg-primary/10 text-primary',
|
||
};
|
||
|
||
/**
|
||
* The reader's info bar (external content, calendar invitation, read
|
||
* receipt): a round icon, a small caps label, the line, then buttons.
|
||
* `compact` drops the circle for a one-line bar.
|
||
*/
|
||
export function InfoBar({ tone = 'neutral', icon, eyebrow, title, children, compact = false }: {
|
||
tone?: Tone; icon?: string; eyebrow?: React.ReactNode; title?: React.ReactNode; compact?: boolean;
|
||
} & Children) {
|
||
if (compact) {
|
||
return (
|
||
<div className="bk flex flex-wrap items-center gap-x-3 gap-y-1.5 px-6 py-1.5 border-b border-border bg-muted/30 text-sm">
|
||
{icon && <span className={cn('w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0', INFO_TONE[tone])}><Icon name={icon} size="sm" /></span>}
|
||
{eyebrow && <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{eyebrow}</span>}
|
||
{title && <span className="min-w-0 flex-1 text-foreground break-words">{title}</span>}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="bk border-b border-border bg-muted/30">
|
||
<div className="px-6 py-1.5">
|
||
<div className="flex items-start gap-3 py-1">
|
||
{icon && (
|
||
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm', INFO_TONE[tone])}>
|
||
<Icon name={icon} size="lg" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0 space-y-2">
|
||
{(eyebrow || title) && (
|
||
<div>
|
||
{eyebrow && <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{eyebrow}</div>}
|
||
{title && <div className="text-sm font-medium text-foreground break-words">{title}</div>}
|
||
</div>
|
||
)}
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The bordered buttons of the reader's info bars ("Load images", RSVP). */
|
||
export function BarButton({ icon, children, onClick, active = false, busy = false, disabled }: { icon?: string; onClick?: () => void; active?: boolean; busy?: boolean; disabled?: boolean } & Children) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled || busy}
|
||
aria-pressed={active || undefined}
|
||
className={cn(
|
||
'bk inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors min-h-[36px] disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
active ? 'bg-primary/10 text-primary border-primary/30 font-medium' : 'text-muted-foreground hover:text-foreground border-border hover:bg-muted',
|
||
)}
|
||
>
|
||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" aria-hidden="true" /> : icon ? <Icon name={icon} size="sm" /> : null}
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const ALERT_TONE: Record<Exclude<Tone, 'primary' | 'neutral'> | 'neutral', string> = {
|
||
neutral: 'bg-muted text-foreground',
|
||
info: 'bg-info/10 text-foreground',
|
||
success: 'bg-success/10 text-success',
|
||
warning: 'bg-warning/10 text-foreground',
|
||
danger: 'bg-destructive/10 text-destructive',
|
||
};
|
||
|
||
/** The Files app's error bar, in other tones too. */
|
||
export function Alert({ tone = 'danger', icon, children, action }: { tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; icon?: string; action?: React.ReactNode } & Children) {
|
||
return (
|
||
<div className={cn('bk px-3 py-2 rounded-md text-sm flex items-center gap-2', ALERT_TONE[tone])} role={tone === 'danger' ? 'alert' : 'status'}>
|
||
{icon && <Icon name={icon} />}
|
||
<div className="flex-1 min-w-0 break-words">{children}</div>
|
||
{action}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A thin progress bar; value 0..1, or null while unknown. */
|
||
export function ProgressBar({ value, label }: { value: number | null; label?: string }) {
|
||
const pct = value === null ? null : Math.max(0, Math.min(1, value)) * 100;
|
||
return (
|
||
<div className="bk h-1.5 w-full rounded-full bg-muted overflow-hidden" role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct === null ? undefined : Math.round(pct)}>
|
||
<div className={cn('h-full bg-primary transition-[width] duration-200', pct === null && 'w-1/3 animate-pulse')} style={pct === null ? undefined : { width: `${pct}%` }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The Files app's empty state: icon in a circle, title, text, buttons. */
|
||
export function EmptyState({ icon, title, description, actions, compact = false }: { icon?: string; title: React.ReactNode; description?: React.ReactNode; actions?: React.ReactNode; compact?: boolean }) {
|
||
return (
|
||
<div className={cn('bk flex items-center justify-center', compact ? 'p-6' : 'min-h-[18rem] p-8')}>
|
||
<div className="flex flex-col items-center text-center max-w-sm">
|
||
{icon && (
|
||
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4">
|
||
<Icon name={icon} className="w-7 h-7 text-muted-foreground" />
|
||
</div>
|
||
)}
|
||
<h3 className="text-base font-medium text-foreground">{title}</h3>
|
||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
||
{actions && <div className="flex gap-2 flex-wrap justify-center mt-5">{actions}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Sidebar sections ──────────────────────────────────────────
|
||
|
||
/** A row in a sidebar section, like a folder row. */
|
||
export function SidebarRow({ icon, label, detail, onClick, title, ariaLabel }: { icon?: string; label: React.ReactNode; detail?: React.ReactNode; onClick?: () => void; title?: string; /** What a click does, for screen readers. */ ariaLabel?: string }) {
|
||
const Tag = onClick ? 'button' : 'div';
|
||
return (
|
||
<Tag
|
||
{...(onClick ? { type: 'button' as const, onClick } : {})}
|
||
title={title}
|
||
aria-label={ariaLabel}
|
||
className={cn('bk w-full flex items-center gap-2 ps-[28px] pe-3 text-sm text-foreground text-start border-s-2 border-transparent transition-colors duration-150', onClick && 'hover:bg-muted/50 focus-visible:outline-none focus-visible:bg-muted/50')}
|
||
// Folder rows are 44px tall on phones (max-lg); the frame's own
|
||
// viewport can't tell, so the page passes the height in.
|
||
style={{ paddingBlock: 'var(--density-sidebar-py)', minHeight: 'var(--touch-row-min-h, 0px)' }}
|
||
>
|
||
{icon && <span className="flex items-center justify-center flex-shrink-0 w-4 h-4 text-muted-foreground"><Icon name={icon} /></span>}
|
||
<span className="flex-1 min-w-0 truncate">{label}</span>
|
||
{detail && <span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">{detail}</span>}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
/** The sidebar's "nothing here" line (as under Folders while loading). */
|
||
export function SidebarEmpty({ children }: Children) {
|
||
return <p className="bk px-4 py-2 text-sm text-muted-foreground">{children}</p>;
|
||
}
|
||
|
||
// ─── Changes ───────────────────────────────────────────────────
|
||
|
||
export interface DiffPart {
|
||
type: 'same' | 'add' | 'del';
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* A text with its changes marked: removed words struck through in red,
|
||
* added ones highlighted. For previews of a rewrite before it is applied.
|
||
* `streaming` shows a caret at the end while the text is still coming in.
|
||
*/
|
||
export function DiffView({ parts, streaming = false, label }: { parts: DiffPart[]; streaming?: boolean; label?: string }) {
|
||
return (
|
||
<div className="bk rounded-md border border-border bg-muted/30 p-3 text-sm text-foreground leading-relaxed whitespace-pre-wrap break-words" aria-label={label} aria-busy={streaming || undefined}>
|
||
{parts.map((p, i) => (
|
||
p.type === 'del'
|
||
? <del key={i} className="rounded-sm bg-destructive/10 text-destructive line-through decoration-destructive/60">{p.text}</del>
|
||
: p.type === 'add'
|
||
? <ins key={i} className="rounded-sm bg-success/15 text-foreground no-underline">{p.text}</ins>
|
||
: <span key={i}>{p.text}</span>
|
||
))}
|
||
{streaming && <span className="ms-0.5 inline-block h-4 w-1.5 translate-y-0.5 animate-pulse rounded-sm bg-primary/60" aria-hidden="true" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Panels and details ────────────────────────────────────────
|
||
|
||
/** The padding of a side panel or detail section (message details, composer side panels). */
|
||
export function Panel({ children }: Children) {
|
||
return <div className="bk p-4 space-y-3 text-sm text-foreground">{children}</div>;
|
||
}
|
||
|
||
/** The small caps heading of the message details' sections. */
|
||
export function DetailHeading({ children }: Children) {
|
||
return <div className="bk text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">{children}</div>;
|
||
}
|
||
|
||
/** Label and value rows like the message details (From, Date, Size...). Children are DetailRows. */
|
||
export function DetailList({ children }: Children) {
|
||
return <dl className="bk grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">{children}</dl>;
|
||
}
|
||
|
||
export function DetailRow({ label, children, mono = false }: { label: React.ReactNode; mono?: boolean } & Children) {
|
||
return (
|
||
<>
|
||
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
|
||
<dd className={cn('text-sm text-foreground min-w-0 break-words', mono && 'font-mono text-xs')}>{children}</dd>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A small round dot in a colour the data carries, like a calendar's or a
|
||
* tag's colour. Only #rgb/#rrggbb values are used; anything else shows the
|
||
* muted colour.
|
||
*/
|
||
export function ColorDot({ color, label }: { color?: string; label?: string }) {
|
||
const safe = typeof color === 'string' && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : undefined;
|
||
return (
|
||
<span
|
||
className={cn('bk inline-block h-2.5 w-2.5 shrink-0 rounded-full', !safe && 'bg-muted-foreground')}
|
||
style={safe ? { backgroundColor: safe } : undefined}
|
||
role={label ? 'img' : undefined}
|
||
aria-label={label}
|
||
aria-hidden={label ? undefined : true}
|
||
/>
|
||
);
|
||
}
|