import type { FormTheme } from "./react_form_styles_types.ts";
// [custom property, light default, the theme field that overrides it]
const LIGHT: readonly (readonly [string, string, keyof FormTheme | null])[] = [
["text", "#0b0c0c", "text"],
["muted", "#505a5f", "muted"],
["background", "#ffffff", "background"],
["border", "#0b0c0c", "border"],
["accent", "#1d70b8", "accent"],
["accent-text", "#ffffff", null],
["secondary", "#f3f2f1", null],
["focus", "#ffdd00", "focus"],
["focus-text", "#0b0c0c", null],
["error", "#d4351c", "error"],
["radius", "0", "radius"],
["font", 'system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif', "font"],
];
// The dark palette always replaces the light colours, the theme's included:
// a colour chosen for a light page is rarely readable on a dark one.
const DARK: readonly (readonly [string, string])[] = [
["text", "#f3f2f1"],
["muted", "#b1b4b6"],
["background", "#0b0c0c"],
["border", "#b1b4b6"],
["accent", "#5694ca"],
["accent-text", "#0b0c0c"],
["secondary", "#383f43"],
["error", "#f47067"],
];
const FOCUS_RING = "outline:3px solid var(--caps-form-focus);outline-offset:0;box-shadow:0 0 0 6px var(--caps-form-focus-text)";
// [selectors, declarations]; every selector is prefixed with the scope.
const RULES: readonly (readonly [string, string])[] = [
[".fune-field", "margin:0 0 1.875rem;font-family:var(--caps-form-font);font-size:1.1875rem;line-height:1.3;color:var(--caps-form-text)"],
[".fune-field--error", "padding-left:.9375rem;border-left:5px solid var(--caps-form-error)"],
[".fune-fieldset", "min-width:0;margin:0;padding:0;border:0"],
[".fune-label,.fune-legend", "display:block;max-width:100%;margin:0 0 .3125rem;padding:0;font-weight:400"],
[".fune-legend--heading", "margin-bottom:.9375rem"],
[".fune-legend-heading", "margin:0;font-size:2rem;font-weight:700;line-height:1.1"],
[".fune-hint", "margin:0 0 .9375rem;color:var(--caps-form-muted)"],
[".fune-error-message", "margin:0 0 .9375rem;font-weight:700;color:var(--caps-form-error)"],
[".fune-visually-hidden", "position:absolute!important;width:1px!important;height:1px!important;margin:0!important;padding:0!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;clip-path:inset(50%)!important;border:0!important;white-space:nowrap!important"],
[".fune-input,.fune-textarea,.fune-select", "box-sizing:border-box;min-height:2.75rem;max-width:100%;padding:.3125rem .5rem;border:2px solid var(--caps-form-border);border-radius:var(--caps-form-radius);background:var(--caps-form-background);color:var(--caps-form-text);font:inherit"],
[".fune-input,.fune-textarea", "width:100%"],
[".fune-textarea", "display:block;min-height:6rem;resize:vertical"],
[".fune-select", "min-width:11.5em"],
[".fune-input--error,.fune-textarea--error,.fune-select--error", "border-color:var(--caps-form-error)"],
[".fune-input:focus,.fune-textarea:focus,.fune-select:focus", "outline:3px solid var(--caps-form-focus);outline-offset:0;box-shadow:inset 0 0 0 2px var(--caps-form-border)"],
[".fune-input:disabled,.fune-textarea:disabled,.fune-select:disabled", "opacity:.5;cursor:not-allowed"],
[".fune-input--width-2", "max-width:5.4ex"],
[".fune-input--width-4", "max-width:9ex"],
[".fune-input-wrapper", "display:flex"],
[".fune-input-wrapper .fune-input", "min-width:0"],
[".fune-input-prefix,.fune-input-suffix", "display:flex;align-items:center;padding:0 .5rem;border:2px solid var(--caps-form-border);background:var(--caps-form-secondary);white-space:nowrap"],
[".fune-input-prefix", "border-right:0"],
[".fune-input-suffix", "border-left:0"],
[".fune-character-count__message", "margin:.3125rem 0 0;color:var(--caps-form-muted)"],
[".fune-character-count__message--over", "font-weight:700;color:var(--caps-form-error)"],
[".fune-checkbox,.fune-radio", "display:grid;grid-template-columns:2.75rem 1fr;column-gap:.5rem;align-items:center;margin:0 0 .625rem"],
[".fune-checkbox-input,.fune-radio-input", "justify-self:center;width:2.5rem;height:2.5rem;margin:0;accent-color:var(--caps-form-accent);cursor:pointer"],
[".fune-checkboxes--small .fune-checkbox-input,.fune-radios--small .fune-radio-input", "width:1.5rem;height:1.5rem"],
[".fune-checkbox-input:focus-visible,.fune-radio-input:focus-visible", FOCUS_RING],
[".fune-checkbox-label,.fune-radio-label", "display:flex;align-items:center;min-height:2.75rem;margin:0;cursor:pointer"],
[".fune-checkbox-hint,.fune-radio-hint", "grid-column:2;margin:0 0 .3125rem"],
[".fune-checkboxes-divider,.fune-radios-divider", "width:2.75rem;margin:0 0 .625rem;text-align:center"],
[".fune-radios--inline", "display:flex;flex-wrap:wrap;column-gap:1.25rem"],
[".fune-date-input", "display:flex;flex-wrap:wrap;gap:0 1.25rem"],
[".fune-date-input-item .fune-field", "margin-bottom:0"],
[".fune-password-field__toggle", "min-width:4.5rem;min-height:2.75rem;margin-left:.5rem;padding:0 .75rem;border:2px solid var(--caps-form-border);border-radius:var(--caps-form-radius);background:var(--caps-form-secondary);color:var(--caps-form-text);font:inherit;font-weight:700;cursor:pointer"],
[".fune-password-field__toggle:focus-visible", FOCUS_RING],
[".fune-password-checklist", "margin:.625rem 0 0;padding:0;list-style:none"],
[".fune-password-checklist__item", "position:relative;margin:0 0 .3125rem;padding-left:1.5rem"],
// A mark as well as the colour: colour alone must not carry meaning (WCAG 1.4.1).
[".fune-password-checklist__item::before", "position:absolute;left:0;font-weight:700"],
[".fune-password-checklist__item--met::before", 'content:"\\2713"'],
[".fune-password-checklist__item--unmet", "color:var(--caps-form-muted)"],
[".fune-password-checklist__item--unmet::before", 'content:"\\2013"'],
[".fune-button", "display:inline-flex;align-items:center;justify-content:center;gap:.5rem;box-sizing:border-box;min-height:2.75rem;margin:0 0 1.875rem;padding:.5rem 1rem;border:2px solid transparent;border-radius:var(--caps-form-radius);background:var(--caps-form-accent);color:var(--caps-form-accent-text);font-family:var(--caps-form-font);font-size:1.1875rem;font-weight:700;line-height:1.2;text-decoration:none;cursor:pointer"],
[".fune-button--secondary", "border-color:var(--caps-form-border);background:var(--caps-form-secondary);color:var(--caps-form-text)"],
[".fune-button--warning", "background:var(--caps-form-error);color:var(--caps-form-accent-text)"],
[".fune-button:hover", "filter:brightness(.9)"],
[".fune-button:focus-visible", "outline:3px solid transparent;background:var(--caps-form-focus);color:var(--caps-form-focus-text);box-shadow:inset 0 -4px 0 var(--caps-form-focus-text)"],
[".fune-button:disabled,.fune-button[aria-disabled=true],.fune-button--loading", "opacity:.6;filter:none;cursor:not-allowed"],
[".fune-error-summary", "margin:0 0 1.875rem;padding:1rem;border:5px solid var(--caps-form-error);color:var(--caps-form-text);font-family:var(--caps-form-font);font-size:1.1875rem;line-height:1.3"],
[".fune-error-summary:focus", FOCUS_RING],
[".fune-error-summary-title", "margin:0 0 .9375rem;font-size:1.5rem;font-weight:700;line-height:1.25"],
[".fune-error-summary-list", "margin:0;padding:0;list-style:none"],
[".fune-error-summary-list li", "margin:0 0 .3125rem"],
[".fune-error-summary a", "font-weight:700;color:var(--caps-form-error)"],
[".fune-error-summary a:focus", "outline:3px solid transparent;background:var(--caps-form-focus);color:var(--caps-form-focus-text);box-shadow:0 4px var(--caps-form-focus-text);text-decoration:none"],
];
/**
* An optional stylesheet for the react.form elements' caps-* classes, as CSS
* text. Everything themeable is a --caps-form-* custom property, so an app
* can restyle it with its own CSS without calling this again.
*/
export function formStyles(theme: FormTheme): string {
const scope = option(theme, "scope");
const dark = option(theme, "dark") ?? "media";
if (dark !== "media" && dark !== "attribute" && dark !== "none") {
throw new Error(`invalid theme.dark: "${dark}" is not media, attribute or none`);
}
const root = scope ?? ":root";
const light = LIGHT.map(([name, value, key]) => `--caps-form-${name}:${(key ? option(theme, key) : null) ?? value}`);
const darkVars = DARK.map(([name, value]) => `--caps-form-${name}:${value}`).join(";") + ";color-scheme:dark";
const lines = [`${root}{${light.join(";")}}`];
if (dark === "media") lines.push(`@media (prefers-color-scheme:dark){${root}{${darkVars}}}`);
if (dark === "attribute") {
const selector = scope ? `[data-theme=dark] ${scope},${scope}[data-theme=dark]` : "[data-theme=dark]";
lines.push(`${selector}{${darkVars}}`);
}
for (const [selectors, declarations] of RULES) {
const scoped = scope ? selectors.split(",").map((s) => `${scope} ${s}`).join(",") : selectors;
lines.push(`${scoped}{${declarations}}`);
}
return lines.join("\n") + "\n";
}
// A theme value, or null when left out or empty. Values go into the CSS as
// they are, so anything that could end a declaration, a rule or the <style>
// element is refused rather than escaped.
function option(theme: FormTheme, key: keyof FormTheme): string | null {
const value = theme[key];
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string") throw new Error(`invalid theme.${key}: must be a string`);
const allowed = key === "scope" ? "" : ">";
for (const ch of value) {
if ((";{}<\\" + allowed).includes(ch)) throw new Error(`invalid theme.${key}: "${ch}" is not allowed, it could break out of the stylesheet`);
if (ch < " " || ch === "\u007f") throw new Error(`invalid theme.${key}: control characters are not allowed`);
}
if (value.includes("/*")) throw new Error(`invalid theme.${key}: "/*" is not allowed, it could break out of the stylesheet`);
if (key === "scope" && value.includes(",")) throw new Error(`invalid theme.scope: one selector only, no ","`);
let quote = "";
for (const ch of value) {
if (quote) {
if (ch === quote) quote = "";
} else if (ch === '"' || ch === "'") {
quote = ch;
}
}
if (quote) throw new Error(`invalid theme.${key}: a quote is not closed`);
return value;
}