This repository has been archived on 2026-10-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mortar/src/primitives.tsx
T
tony 43fe76158c
Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s
Extract independent MIT Mortar kit and contribution workflow
2026-10-13 22:16:21 +01:00

430 lines
10 KiB
TypeScript

import {
forwardRef,
useId,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
type ReactNode,
} from "react";
import {
Dialog as HeadlessDialog,
DialogPanel,
DialogTitle,
Menu,
MenuButton,
MenuItems,
MenuItem,
} from "@headlessui/react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
loading = false,
disabled,
children,
className = "",
type = "button",
...props
},
ref,
) {
return (
<button
{...props}
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={`m-button m-button-${variant} ${className}`}
>
{loading && <span className="m-spinner" aria-hidden="true" />}
{children}
</button>
);
},
);
export interface LinkAction {
label: string;
href: string;
}
export function ActionLink({
label,
href,
variant = "primary",
}: LinkAction & { variant?: ButtonProps["variant"] }) {
return (
<a className={`m-button m-button-${variant}`} href={href}>
{label}
</a>
);
}
export type Tone = "neutral" | "success" | "warning" | "danger";
export function Badge({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: Tone;
}) {
return <span className={`m-badge m-tone-${tone}`}>{children}</span>;
}
interface FieldDescription {
label: string;
hint?: string;
error?: string;
}
export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>, FieldDescription {
prefix?: string;
suffix?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, id, prefix, suffix, className = "", ...props },
ref,
) {
const generated = useId(),
fieldId = id || generated,
messageId = fieldId + "-message";
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-input-group" data-invalid={!!error || undefined}>
{prefix && <span>{prefix}</span>}
<input
{...props}
ref={ref}
id={fieldId}
className={className}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (error || hint) && messageId]
.filter(Boolean)
.join(" ") || undefined
}
/>
{suffix && <span>{suffix}</span>}
</div>
{(error || hint) && (
<p id={messageId} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
});
export function Select({
label,
options,
id,
hint,
error,
...props
}: SelectHTMLAttributes<HTMLSelectElement> &
FieldDescription & { options: { value: string; label: string }[] }) {
const generated = useId(),
fieldId = id || generated,
listbox = !!props.multiple || (props.size || 0) > 1;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-select-control" data-listbox={listbox || undefined}>
<select
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{!listbox && (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
focusable="false"
>
<path
d="m6 8 4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function TextArea({
label,
id,
hint,
error,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & FieldDescription) {
const generated = useId(),
fieldId = id || generated;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<textarea
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
/>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function Checkbox({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
return (
<label className="m-choice">
<input {...props} type="checkbox" />
{label}
</label>
);
}
export function Switch({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "role"> & {
label: string;
}) {
return (
<label className="m-choice">
<input {...props} type="checkbox" role="switch" className="m-switch" />
{label}
</label>
);
}
export function RadioGroup({
label,
name,
value,
onChange,
options,
}: {
label: string;
name: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string; description?: string }[];
}) {
return (
<fieldset className="m-radio-group">
<legend>{label}</legend>
{options.map((o) => (
<label className="m-radio-card" key={o.value}>
<input
type="radio"
name={name}
value={o.value}
checked={value === o.value}
onChange={() => onChange(o.value)}
/>
<span>
<strong>{o.label}</strong>
{o.description && <span>{o.description}</span>}
</span>
</label>
))}
</fieldset>
);
}
export function Tabs({
label,
value,
onChange,
items,
}: {
label: string;
value: string;
onChange: (value: string) => void;
items: { id: string; label: string; content: ReactNode }[];
}) {
const base = useId();
return (
<div>
<div
className="m-tabs"
role="tablist"
aria-label={label}
onKeyDown={(e) => {
const i = items.findIndex((item) => item.id === value);
let next = i;
if (e.key === "ArrowRight") next = (i + 1) % items.length;
else if (e.key === "ArrowLeft")
next = (i - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
onChange(items[next].id);
document.getElementById(`${base}-${items[next].id}-tab`)?.focus();
}}
>
{items.map((item) => (
<button
key={item.id}
id={`${base}-${item.id}-tab`}
type="button"
role="tab"
aria-selected={item.id === value}
aria-controls={`${base}-${item.id}-panel`}
tabIndex={item.id === value ? 0 : -1}
onClick={() => onChange(item.id)}
>
{item.label}
</button>
))}
</div>
{items.map((item) => (
<div
key={item.id}
hidden={item.id !== value}
role="tabpanel"
id={`${base}-${item.id}-panel`}
aria-labelledby={`${base}-${item.id}-tab`}
tabIndex={0}
>
{item.content}
</div>
))}
</div>
);
}
export function Modal({
open,
onClose,
title,
children,
actions,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<HeadlessDialog open={open} onClose={onClose} className="m-dialog">
<div className="m-backdrop" aria-hidden="true" />
<div className="m-dialog-position">
<DialogPanel className="m-dialog-panel">
<div className="m-dialog-heading">
<DialogTitle>{title}</DialogTitle>
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
Close
</Button>
</div>
{children}
{actions && <div className="m-actions">{actions}</div>}
</DialogPanel>
</div>
</HeadlessDialog>
);
}
export function Dropdown({
label,
items,
}: {
label: string;
items: {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
}[];
}) {
return (
<Menu>
<MenuButton className="m-button m-button-secondary">
{label}
<span aria-hidden="true">⌄</span>
</MenuButton>
<MenuItems anchor="bottom end" className="m-menu">
{items.map((item) => (
<MenuItem key={item.label} disabled={item.disabled}>
<button
type="button"
className={item.danger ? "m-error" : ""}
onClick={item.onSelect}
>
{item.label}
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
export function Notice({
title,
children,
tone = "neutral",
announce = false,
}: {
title: string;
children?: ReactNode;
tone?: Tone;
announce?: boolean;
}) {
return (
<div
className={`m-notice m-tone-${tone}`}
role={announce ? (tone === "danger" ? "alert" : "status") : undefined}
>
<strong>{title}</strong>
{children && <div>{children}</div>}
</div>
);
}
export function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="m-empty">
<h3>{title}</h3>
<p>{description}</p>
{action}
</div>
);
}