Files
webmail/lib/plugin-sandbox/ui-kit/components.tsx
T

1383 lines
57 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/cn';
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, ChevronDown, ChevronRight, ChevronUp, Loader2, Puzzle, Search, X } from '@/components/icons';
import {
isFloatingDropdownSupported,
openFloatingDropdown,
updateFloatingDropdown,
closeFloatingDropdown,
registerFloatingDropdownListener,
} from './floating-dropdown-bridge';
import { kitString } from './kit-strings';
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-hidden 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-hidden 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 KitComboboxOption {
value: string;
label: string;
disabled?: boolean;
}
export interface KitComboboxProps {
options?: KitComboboxOption[];
/** Currently selected value (string for single, string[] for multiple). */
value?: string | string[] | null;
/** Called with the selected value (string for single, string[] for multiple). */
onChange?: (value: any) => void;
placeholder?: string;
disabled?: boolean;
/** Stretch to the width of its container (forms); settings rows keep it narrow. */
block?: boolean;
/** Allow selecting multiple options (e.g. for tags). */
multiple?: boolean;
/** Whether the user can clear the selected value (default: true). */
clearable?: boolean;
id?: string;
/** Accessible label when not wrapped by Field. */
ariaLabel?: string;
className?: string;
}
/** A searchable combobox with keyboard navigation, clear button and multiselect support. */
export function Combobox({
options = [],
value,
onChange,
placeholder,
disabled = false,
block = false,
multiple = false,
clearable = true,
id,
ariaLabel,
className,
}: KitComboboxProps) {
const autoId = React.useId();
const inputId = id ?? autoId;
const listboxId = `${inputId}-listbox`;
const containerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const optionRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [isOpen, setIsOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const [isTyping, setIsTyping] = React.useState(false);
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const selectedValues = React.useMemo(() => {
if (multiple) {
return Array.isArray(value) ? value : value ? [value] : [];
}
return [];
}, [multiple, value]);
const singleValue = React.useMemo(() => {
if (multiple) return '';
return typeof value === 'string' ? value : '';
}, [multiple, value]);
const selectedOption = React.useMemo(() => {
if (multiple || !singleValue) return null;
return options.find((o) => o.value === singleValue) || null;
}, [multiple, singleValue, options]);
const selectedOptions = React.useMemo(() => {
if (!multiple) return [];
return options.filter((o) => selectedValues.includes(o.value));
}, [multiple, options, selectedValues]);
// Synchronize input query with selected option label when not typing in single-select mode
React.useEffect(() => {
if (!isTyping) {
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
}, [selectedOption, isTyping, multiple]);
const filteredOptions = React.useMemo(() => {
if (!isTyping || !query.trim()) return options;
const q = query.trim().toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, isTyping, query]);
// When opening dropdown, pre-highlight the currently selected item or first item
React.useEffect(() => {
if (isOpen) {
if (multiple) {
setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
} else {
const idx = filteredOptions.findIndex((o) => o.value === singleValue);
setHighlightedIndex(idx >= 0 ? idx : filteredOptions.length > 0 ? 0 : -1);
}
} else {
setHighlightedIndex(-1);
}
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-scroll highlighted option into view
React.useEffect(() => {
if (isOpen && highlightedIndex >= 0 && optionRefs.current[highlightedIndex]) {
const el = optionRefs.current[highlightedIndex];
if (typeof el?.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'nearest' });
}
}
}, [isOpen, highlightedIndex]);
// Close when clicking outside
React.useEffect(() => {
if (!isOpen) return;
const onDocMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [isOpen, selectedOption, multiple]);
const toggleMultipleOption = (opt: KitComboboxOption) => {
if (opt.disabled) return;
const exists = selectedValues.includes(opt.value);
const next = exists ? selectedValues.filter((v) => v !== opt.value) : [...selectedValues, opt.value];
onChange?.(next);
setQuery('');
setIsTyping(false);
if (isOpen) {
inputRef.current?.focus();
}
};
const selectOption = (opt: KitComboboxOption) => {
if (opt.disabled) return;
if (multiple) {
toggleMultipleOption(opt);
} else {
onChange?.(opt.value);
setIsOpen(false);
setIsTyping(false);
setQuery(opt.value ? opt.label : '');
inputRef.current?.focus();
}
};
const isHostFloating = isFloatingDropdownSupported();
const getContainerRect = () => {
if (!containerRef.current) return null;
const r = containerRef.current.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
bottom: Math.round(r.bottom),
width: Math.round(r.width),
height: Math.round(r.height),
};
};
const selectOptionRef = React.useRef(selectOption);
selectOptionRef.current = selectOption;
const selectedOptionRef = React.useRef(selectedOption);
selectedOptionRef.current = selectedOption;
// When isOpen changes: if host floating dropdown is active, open it on host and register listener
React.useEffect(() => {
if (!isHostFloating) return;
if (isOpen) {
const rect = getContainerRect();
if (!rect) return;
openFloatingDropdown({
id: listboxId,
rect,
options: filteredOptions,
multiple,
selectedValues,
singleValue,
highlightedIndex,
ariaLabel: ariaLabel ?? placeholder,
});
const unregister = registerFloatingDropdownListener(listboxId, (ev) => {
if (ev.type === 'select') {
selectOptionRef.current(ev.option);
} else if (ev.type === 'highlight') {
setHighlightedIndex(ev.index);
} else if (ev.type === 'dismiss') {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOptionRef.current ? selectedOptionRef.current.label : '');
}
}
});
return () => {
unregister();
closeFloatingDropdown(listboxId);
};
}
}, [isOpen, isHostFloating, listboxId]); // eslint-disable-line react-hooks/exhaustive-deps
// Push updates to host dropdown when options, selection, or highlight changes while open
React.useEffect(() => {
if (!isHostFloating || !isOpen) return;
const rect = getContainerRect();
updateFloatingDropdown({
id: listboxId,
...(rect ? { rect } : {}),
options: filteredOptions,
multiple,
selectedValues,
singleValue,
highlightedIndex,
});
}, [isHostFloating, isOpen, filteredOptions, selectedValues, singleValue, highlightedIndex, multiple, listboxId]);
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (multiple) {
onChange?.([]);
} else {
onChange?.('');
}
setQuery('');
setIsTyping(false);
setIsOpen(false);
};
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (isOpen) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
} else {
setIsOpen(true);
setIsTyping(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setIsTyping(false);
} else if (filteredOptions.length > 0) {
setHighlightedIndex((prev) => (prev + 1 < filteredOptions.length ? prev + 1 : 0));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setIsTyping(false);
} else if (filteredOptions.length > 0) {
setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : filteredOptions.length - 1));
}
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
selectOption(filteredOptions[highlightedIndex]);
}
} else if (e.key === 'Escape') {
if (isOpen) {
e.preventDefault();
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
} else if (e.key === 'Tab') {
if (isOpen) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
} else if (e.key === 'Backspace' && multiple && query === '' && selectedValues.length > 0) {
const next = selectedValues.slice(0, -1);
onChange?.(next);
}
};
const hasValue = multiple ? selectedValues.length > 0 : Boolean(singleValue);
return (
<div
ref={containerRef}
className={cn('bk relative', block ? 'w-full' : 'w-56', className)}
>
<div
onClick={() => {
if (!disabled) inputRef.current?.focus();
}}
className={cn(
'relative w-full rounded-md border border-input bg-background transition-colors duration-150 focus-within:ring-2 focus-within:ring-ring hover:border-muted-foreground',
disabled && 'cursor-not-allowed opacity-50',
multiple ? 'flex flex-wrap items-center gap-1.5 p-1.5 min-h-[38px] pe-14' : 'flex items-center',
)}
>
{multiple &&
selectedOptions.map((opt) => (
<span
key={opt.value}
role="checkbox"
aria-checked="true"
aria-label={opt.label}
className="bk inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs text-foreground font-medium max-w-full"
>
<span className="truncate">{opt.label}</span>
{!disabled && (
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleMultipleOption(opt);
}}
aria-label={kitString('remove_named', { name: String(opt.label) })}
className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-hidden"
>
<X className="w-3 h-3" aria-hidden="true" />
</button>
)}
</span>
))}
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
autoComplete="off"
disabled={disabled}
aria-expanded={isOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-label={ariaLabel}
aria-activedescendant={isOpen && highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined}
value={query}
placeholder={multiple && selectedValues.length > 0 ? '' : placeholder}
onChange={(e) => {
setQuery(e.target.value);
setIsTyping(true);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => {
if (!disabled && !isOpen) {
setIsOpen(true);
setIsTyping(false);
}
}}
onClick={() => {
if (!disabled && !isOpen) {
setIsOpen(true);
setIsTyping(false);
}
}}
onKeyDown={handleKeyDown}
className={cn(
multiple
? 'flex-1 min-w-[80px] bg-transparent border-0 p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none'
: cn(
'w-full rounded-md border-0 bg-transparent ps-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none',
clearable && hasValue ? 'pe-14' : 'pe-8',
),
)}
/>
<div className="absolute end-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-muted-foreground pointer-events-auto">
{clearable && hasValue && !disabled && (
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={handleClear}
aria-label={kitString('clear')}
className="p-1 rounded hover:text-foreground focus:outline-hidden"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
<button
type="button"
tabIndex={-1}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={handleToggle}
aria-label={isOpen ? kitString('close') : kitString('open')}
className="p-1 rounded hover:text-foreground focus:outline-hidden disabled:opacity-50"
>
{isOpen ? <ChevronUp className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />}
</button>
</div>
</div>
{isOpen && !isHostFloating && (
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-multiselectable={multiple || undefined}
aria-label={ariaLabel ?? placeholder}
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 focus:outline-hidden"
>
{filteredOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground text-center">
No results
</div>
) : (
filteredOptions.map((opt, i) => {
const isSelected = multiple ? selectedValues.includes(opt.value) : opt.value === singleValue;
const isHighlighted = highlightedIndex === i;
return (
<div
key={opt.value}
ref={(el) => { optionRefs.current[i] = el; }}
id={`${listboxId}-opt-${i}`}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectOption(opt)}
onMouseEnter={() => setHighlightedIndex(i)}
className={cn(
'relative flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors',
isHighlighted ? 'bg-accent text-accent-foreground' : 'text-foreground',
isSelected && 'font-medium',
opt.disabled && 'pointer-events-none opacity-50',
)}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check className="w-4 h-4 ms-2 shrink-0 text-primary" aria-hidden="true" />}
</div>
);
})
)}
</div>
)}
</div>
);
}
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-hidden 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 shrink-0 items-center rounded-full transition-colors duration-150 focus-visible:outline-hidden',
checked ? 'bg-primary' : 'bg-muted ring-1 ring-inset ring-muted-foreground',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full transition-transform duration-150',
checked ? 'bg-background ltr:translate-x-6 rtl:-translate-x-6' : 'bg-muted-foreground 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-hidden',
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();
type ControlProps = { id?: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean; 'aria-required'?: boolean };
const only = React.Children.count(children) === 1 && React.isValidElement(children) ? children as React.ReactElement<ControlProps> : null;
const controlId = only?.props.id ?? autoId;
// The hint or error is read with the field, an error marks it invalid,
// and the asterisk is said as "required".
const noteId = `${autoId}-note`;
const note = error || hint;
const control = only
? React.cloneElement(only, {
id: controlId,
'aria-describedby': [only.props['aria-describedby'], note ? noteId : null].filter(Boolean).join(' ') || undefined,
...(error ? { 'aria-invalid': true } : {}),
...(required ? { 'aria-required': true } : {}),
})
: children;
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" aria-hidden="true"> *</span>}
</label>
{control}
{hint && !error && <p id={noteId} className="text-xs text-muted-foreground mt-1">{hint}</p>}
{error && <p id={noteId} 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 [&::-webkit-search-cancel-button]:hidden"
/>
{value && (
<button type="button" onClick={() => onChange('')} aria-label={kitString('clear_search')} 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-hidden focus-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-inset',
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-hidden', 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-[0.6875rem] 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-hidden')}>{body}</button>;
}
return (
<span className={box} title={title}>
{body}
{onRemove && (
<button type="button" onClick={onRemove} aria-label={removeLabel ?? (typeof label === 'string' ? kitString('remove_named', { name: label }) : kitString('remove'))} 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-[0.6875rem] font-semibold 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-[0.6875rem] font-semibold 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-hidden',
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-hidden 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-[0.6875rem] font-semibold text-muted-foreground 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}
/>
);
}