Functional Weave
Code in Rust

react.form.styles@1.0.0

impl/typescript.ts

10,262 bytes · the TypeScript implementation · view raw

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;
}