Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s
430 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|