Domphy

signup01

A Auth block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call signup01() with no arguments for a working demo, or edit the code below live.

Implementation notes

Minimal single-card signup form: card() patch (@domphy/ui) auto-places h2/p/div/footer into title/desc/content/footer grid areas; Full Name, Email(+caption), Password(+caption, minlength=8), Confirm Password(+caption) fields; solid dark submit button (dataTone shift-17 edge anchor + button({color:'neutral'})) directly followed by an outline Google button with NO divider between them, matching the spec's researchNote. Reactive loading (spinner + aria-busy + disabled) and error (alert banner) props wired via toState. Deviation: does NOT reuse @domphy/ui's inputText()/inputPassword() patches for the actual <input> elements — inputText() forces type='text' via an unconditional _onSchedule hook (confirmed by reading packages/ui/src/patches/inputText.ts and tracing ElementNode's constructor order), which would silently unmask password fields and break type=email semantics; inputPassword() is a div-wrapper that builds its own internal <input> imperatively with no id/name/required/autocomplete passthrough. Built a small local authFieldInput() patch replicating inputText()'s exact visual formula (theme tokens only) instead, preserving correct native type/required/minlength/autocomplete attributes as the spec's behavior section demands. Google glyph is an original brand-neutral letter-badge SVG (not a reproduction of Google's official mark, since a raw multicolor brand logo would also violate the no-raw-hex-color doctor rule).

Status: ported · Reference: shadcn/ui original

import type {
  DomphyElement,
  Listener,
  PartialElement,
  ValueOrState,
} from "@domphy/core";
import { toState } from "@domphy/core";
import {
  alert,
  button,
  card,
  heading,
  icon,
  label,
  link,
  paragraph,
  small,
  spinner,
} from "@domphy/ui";
import {
  themeColor,
  themeDensity,
  themeFluidSpacing,
  themeSize,
  themeSpacing,
} from "@domphy/theme";

// Generic monochrome "G" glyph — an original, brand-neutral placeholder for
// the Google provider button. Swap for an official brand SVG in production.
const GOOGLE_ICON =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" ' +
  'fill="none" stroke="currentColor" stroke-width="1.5">' +
  '<circle cx="12" cy="12" r="9.25" />' +
  '<text x="12" y="16" text-anchor="middle" font-size="10" stroke="none" fill="currentColor">G</text>' +
  "</svg>";

/**
 * Visual formula for a bounded text-like `<input>`, matching @domphy/ui's
 * `inputText()` patch. Written as a local patch instead of reusing
 * `inputText()` directly because that patch forces `type="text"` via
 * `_onSchedule`, which would silently unmask `type="password"`/`"email"`
 * fields — see the port's fidelity notes.
 */
function authFieldInput(): PartialElement {
  return {
    style: {
      fontFamily: "inherit",
      lineHeight: "inherit",
      width: "100%",
      boxSizing: "border-box",
      paddingInline: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 3),
      paddingBlock: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 1),
      borderRadius: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 1),
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      border: "none",
      outlineOffset: "-1px",
      outline: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-4", "neutral")}`,
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      "&::placeholder": {
        color: (listener: Listener) => themeColor(listener, "shift-7", "neutral"),
      },
      "&:hover:not([disabled]), &:focus-visible": {
        outline: (listener: Listener) =>
          `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "primary")}`,
      },
      "&[disabled]": {
        opacity: 0.7,
        cursor: "not-allowed",
        backgroundColor: (listener: Listener) =>
          themeColor(listener, "shift-2", "neutral"),
      },
    },
  };
}

interface FieldConfig {
  id: string;
  labelText: string;
  type?: "text" | "email" | "password";
  placeholder?: string;
  caption?: string;
  autoComplete?: string;
  minLength?: number;
}

function field(config: FieldConfig): DomphyElement<"div"> {
  const {
    id,
    labelText,
    type = "text",
    placeholder,
    caption,
    autoComplete,
    minLength,
  } = config;

  return {
    div: [
      { label: labelText, for: id, $: [label()] },
      {
        input: null,
        id,
        name: id,
        type,
        placeholder,
        required: true,
        autocomplete: autoComplete,
        minlength: minLength,
        $: [authFieldInput()],
      },
      caption ? { small: caption, $: [small({ color: "neutral" })] } : null,
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
    },
  };
}

/** Props for {@link signup01}. */
export interface Signup01Props {
  title?: string;
  subtitle?: string;
  fullNameLabel?: string;
  fullNamePlaceholder?: string;
  emailLabel?: string;
  emailPlaceholder?: string;
  emailCaption?: string;
  passwordLabel?: string;
  passwordCaption?: string;
  confirmPasswordLabel?: string;
  confirmPasswordCaption?: string;
  submitLabel?: string;
  showGoogleButton?: boolean;
  googleButtonLabel?: string;
  signInPrompt?: string;
  signInLinkText?: string;
  signInHref?: string;
  /** Reactive busy state — disables the submit button and shows a spinner. */
  loading?: ValueOrState<boolean>;
  /** Reactive error message — renders an inline alert above the submit button when set. */
  error?: ValueOrState<string | null>;
  onSubmit?: (event: SubmitEvent) => void;
}

/**
 * shadcn/ui "signup-01" — a minimal single-card signup form centered in the
 * viewport: Full Name, Email, Password, Confirm Password, a solid submit
 * button and an optional outline Google button.
 */
function signup01(props: Signup01Props = {}): DomphyElement<"div"> {
  const {
    title = "Create an account",
    subtitle = "Enter your details below to create your account",
    fullNameLabel = "Full Name",
    fullNamePlaceholder = "John Doe",
    emailLabel = "Email",
    emailPlaceholder = "m@example.com",
    emailCaption = "We'll never share your email with anyone else.",
    passwordLabel = "Password",
    passwordCaption = "Must be at least 8 characters long.",
    confirmPasswordLabel = "Confirm Password",
    confirmPasswordCaption = "Please re-enter your password.",
    submitLabel = "Create Account",
    showGoogleButton = true,
    googleButtonLabel = "Sign up with Google",
    signInPrompt = "Already have an account?",
    signInLinkText = "Sign in",
    signInHref = "#",
    loading = false,
    error = null,
    onSubmit,
  } = props;

  const loadingState = toState(loading, "loading");
  const errorState = toState(error, "error");

  const submitButton: DomphyElement<"button"> = {
    button: (listener: Listener) =>
      loadingState.get(listener)
        ? [{ span: null, $: [spinner({ color: "neutral" })] }, submitLabel]
        : submitLabel,
    type: "submit",
    dataTone: "shift-17",
    disabled: (listener: Listener) => loadingState.get(listener),
    ariaBusy: (listener: Listener) => (loadingState.get(listener) ? "true" : "false"),
    $: [button({ color: "neutral" })],
    style: {
      width: "100%",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };

  const googleButton: DomphyElement<"button"> = {
    button: [
      { span: GOOGLE_ICON, $: [icon({ color: "inherit" })] },
      { span: googleButtonLabel },
    ],
    type: "button",
    $: [button({ color: "neutral" })],
    style: { width: "100%" },
  };

  const errorBanner: DomphyElement<"div"> = {
    div: (listener: Listener) => errorState.get(listener) ?? "",
    hidden: (listener: Listener) => !errorState.get(listener),
    $: [alert({ color: "error" })],
  };

  const cardBody: DomphyElement<"div"> = {
    div: [
      {
        form: [
          field({
            id: "signup01-name",
            labelText: fullNameLabel,
            placeholder: fullNamePlaceholder,
            autoComplete: "name",
          }),
          field({
            id: "signup01-email",
            labelText: emailLabel,
            type: "email",
            placeholder: emailPlaceholder,
            caption: emailCaption,
            autoComplete: "email",
          }),
          field({
            id: "signup01-password",
            labelText: passwordLabel,
            type: "password",
            caption: passwordCaption,
            autoComplete: "new-password",
            minLength: 8,
          }),
          field({
            id: "signup01-confirm-password",
            labelText: confirmPasswordLabel,
            type: "password",
            caption: confirmPasswordCaption,
            autoComplete: "new-password",
          }),
          errorBanner,
          submitButton,
          showGoogleButton ? googleButton : null,
        ],
        onSubmit: (event: Event) => {
          event.preventDefault();
          onSubmit?.(event as SubmitEvent);
        },
        style: {
          display: "flex",
          flexDirection: "column",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
        },
      },
    ],
  };

  const cardFooter: DomphyElement<"footer"> = {
    footer: [
      {
        small: [
          `${signInPrompt} `,
          { a: signInLinkText, href: signInHref, $: [link({ color: "primary" })] },
        ],
        $: [small({ color: "neutral" })],
      },
    ],
  };

  const cardElement: DomphyElement<"div"> = {
    div: [{ h2: title, $: [heading()] }, { p: subtitle, $: [paragraph({ color: "neutral" })] }, cardBody, cardFooter],
    style: {
      width: "100%",
      maxWidth: themeSpacing(96),
    },
    $: [card({ color: "neutral" })],
  };

  return {
    div: [cardElement],
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      minHeight: "100vh",
      paddingInline: themeFluidSpacing(4, 12),
      paddingBlock: themeFluidSpacing(4, 12),
    },
  };
}

export { signup01 };

← Back to shadcn/ui catalog