← alle entries

sign-in

FC-0044sign-inblockv1.0.0

Inlogpagina: e-mail, wachtwoord met oog-knop, aangemeld blijven, wachtwoord vergeten, inloggen, andere aanbieders en account aanmaken; rechts een vrij vlak. Elementen komen na elkaar in beeld.

zelfde component — andere tokens
blocks/sign-in/sign-in.tsx
/**
 * SignIn — de inlogpagina: links het formulier (e-mail, wachtwoord met
 * oog-knop, aangemeld blijven, wachtwoord vergeten, inloggen, andere
 * aanbieders, account aanmaken), rechts een vrij vlak (foto, citaten). Alles
 * komt na elkaar in beeld, van boven naar beneden.
 *
 * Componeert field (FC-0005, met het nieuwe trailing-slot) en button
 * (FC-0003); de bron bouwde eigen glas-inputs met violet-accenten. Het
 * Google-icoon met zijn merkkleuren is vervallen: aanbieders zijn een lijst
 * van het project met een icon-slot, zodat de entry nul kleuren bevat.
 * Inloggen zelf (onSubmit) is een contract van het project (V6).
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Button } from "../../primitives/ui/button";
import { Field } from "../../primitives/ui/input";
import { usePress } from "../../primitives/ui/press";

export interface SignInProvider {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onPress: () => void;
}

export interface SignInLabels {
  email: string;
  emailPlaceholder: string;
  password: string;
  passwordPlaceholder: string;
  showPassword: string;
  hidePassword: string;
  remember: string;
  reset: string;
  submit: string;
  or: string;
  newHere: string;
  create: string;
}

const STANDAARD: SignInLabels = {
  email: "E-mailadres",
  emailPlaceholder: "naam@voorbeeld.nl",
  password: "Wachtwoord",
  passwordPlaceholder: "Je wachtwoord",
  showPassword: "Wachtwoord tonen",
  hidePassword: "Wachtwoord verbergen",
  remember: "Aangemeld blijven",
  reset: "Wachtwoord vergeten?",
  submit: "Inloggen",
  or: "Of ga verder met",
  newHere: "Nog geen account?",
  create: "Account aanmaken",
};

export interface SignInProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "onSubmit"> {
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Het project logt in: krijgt { email, password, remember }. */
  onSubmit: (data: { email: string; password: string; remember: boolean }) => void | Promise<void>;
  onResetPassword?: () => void;
  onCreateAccount?: () => void;
  providers?: readonly SignInProvider[];
  /** Fout van het project (verkeerd wachtwoord e.d.), onder het wachtwoordveld. */
  error?: string;
  pending?: boolean;
  /** Het rechtervlak: foto, citaten, wat je wilt. Zonder = alleen het formulier. */
  aside?: React.ReactNode;
  labels?: Partial<SignInLabels>;
}

function Oog({ open }: { open: boolean }) {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
      <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z" />
      <circle cx="12" cy="12" r="3" />
      {!open && <path d="M4 4l16 16" />}
    </svg>
  );
}

function TekstKnop({ children, onPress, className }: { children: React.ReactNode; onPress?: () => void; className?: string }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className={["fp-signin__link", className].filter(Boolean).join(" ")} data-pressed={pressed ? "" : undefined}>
      {children}
    </button>
  );
}

function AanbiederKnop({ p }: { p: SignInProvider }) {
  const { pressed, pressProps } = usePress({ onPress: p.onPress });
  return (
    <button {...pressProps} type="button" className="fp-signin__aanbieder" data-pressed={pressed ? "" : undefined}>
      {p.icon && <span className="fp-signin__aanbieder-icoon">{p.icon}</span>}
      {p.label}
    </button>
  );
}

export function SignIn({ title, description, onSubmit, onResetPassword, onCreateAccount, providers = [], error, pending = false, aside, labels, className, ...rest }: SignInProps) {
  const t = { ...STANDAARD, ...labels };
  const reduced = useReducedMotion() ?? false;
  const veer = useSpringTransition("smooth", { mass: "content" });
  const [toon, setToon] = useState(false);
  const oog = usePress({ onPress: () => setToon((v) => !v) });

  /* Elk element één stap later dan het vorige. */
  let stap = 0;
  const entree = () => {
    const i = stap++;
    return {
      initial: reduced ? false : { opacity: 0, y: 12 },
      animate: { opacity: 1, y: 0 },
      transition: { ...veer, delay: reduced ? 0 : 0.08 * i },
    } as const;
  };

  const verstuur = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    void onSubmit({
      email: String(fd.get("email") ?? ""),
      password: String(fd.get("password") ?? ""),
      remember: fd.get("remember") === "on",
    });
  };

  return (
    <div {...rest} className={["fp-signin", aside ? "fp-signin--met-aside" : "", className].filter(Boolean).join(" ")}>
      <section className="fp-signin__vorm">
        <div className="fp-signin__kolom">
          {title && <motion.h1 className="fp-signin__titel" {...entree()}>{title}</motion.h1>}
          {description && <motion.p className="fp-signin__tekst" {...entree()}>{description}</motion.p>}
          <form className="fp-signin__form" onSubmit={verstuur}>
            <motion.div {...entree()}>
              <Field label={t.email} name="email" type="email" autoComplete="email" placeholder={t.emailPlaceholder} required />
            </motion.div>
            <motion.div {...entree()}>
              <Field
                label={t.password}
                name="password"
                type={toon ? "text" : "password"}
                autoComplete="current-password"
                placeholder={t.passwordPlaceholder}
                required
                error={error}
                trailing={
                  <button {...oog.pressProps} type="button" className="fp-signin__oog" aria-label={toon ? t.hidePassword : t.showPassword} aria-pressed={toon} data-pressed={oog.pressed ? "" : undefined}>
                    <Oog open={!toon} />
                  </button>
                }
              />
            </motion.div>
            <motion.div className="fp-signin__rij" {...entree()}>
              <label className="fp-signin__onthoud">
                <input type="checkbox" name="remember" className="fp-signin__vinkje" />
                <span>{t.remember}</span>
              </label>
              {onResetPassword && <TekstKnop onPress={onResetPassword}>{t.reset}</TekstKnop>}
            </motion.div>
            <motion.div {...entree()}>
              <Button type="submit" className="fp-signin__submit" disabled={pending} aria-busy={pending || undefined}>
                {t.submit}
              </Button>
            </motion.div>
          </form>
          {providers.length > 0 && (
            <>
              <motion.div className="fp-signin__of" {...entree()}>
                <span className="fp-signin__of-lijn" />
                <span className="fp-signin__of-tekst">{t.or}</span>
              </motion.div>
              {providers.map((p) => (
                <motion.div key={p.id} {...entree()}>
                  <AanbiederKnop p={p} />
                </motion.div>
              ))}
            </>
          )}
          {onCreateAccount && (
            <motion.p className="fp-signin__nieuw" {...entree()}>
              {t.newHere} <TekstKnop onPress={onCreateAccount}>{t.create}</TekstKnop>
            </motion.p>
          )}
        </div>
      </section>
      {aside && (
        <motion.aside
          className="fp-signin__aside"
          initial={reduced ? false : { opacity: 0, x: 40 }}
          animate={{ opacity: 1, x: 0 }}
          transition={{ ...veer, delay: reduced ? 0 : 0.25 }}
        >
          {aside}
        </motion.aside>
      )}
    </div>
  );
}
blocks/sign-in/sign-in.css
/**
 * SignIn — inlogpagina. Importeren in de app-root (de registry-site doet dat
 * in app/globals.css). Velden en knop komen uit primitives.css.
 */

.fp-signin {
  display: flex; flex-direction: column; width: 100%; min-height: 100%;
  background: var(--background); color: var(--foreground);
}
@media (min-width: 768px) { .fp-signin--met-aside { flex-direction: row; } }

.fp-signin__vorm { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; }
.fp-signin__kolom { width: 100%; max-width: 28rem; display: flex; flex-direction: column; gap: 1.5rem; }
.fp-signin__titel { margin: 0; font: inherit; font-weight: 600; font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; letter-spacing: -0.02em; }
.fp-signin__tekst { margin: 0; color: var(--muted-foreground); }
.fp-signin__form { display: flex; flex-direction: column; gap: 1.25rem; }

.fp-signin__oog {
  display: flex; padding: 0.25rem;
  appearance: none; border: 0; background: transparent; cursor: pointer;
  color: var(--muted-foreground); border-radius: calc(var(--radius) / 2);
  transition: color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-signin__oog:hover { color: var(--foreground); }
.fp-signin__oog[data-pressed] { transform: scale(0.92); }
.fp-signin__oog:focus-visible { outline: 2px solid var(--ring, var(--info)); }

.fp-signin__rij { display: flex; align-items: center; justify-content: space-between; gap: 1rem; font-size: 0.875rem; }
.fp-signin__onthoud { display: flex; align-items: center; gap: 0.625rem; cursor: pointer; }
.fp-signin__vinkje { width: 1rem; height: 1rem; margin: 0; accent-color: var(--primary); cursor: pointer; }
.fp-signin__link {
  appearance: none; border: 0; background: transparent; padding: 0; cursor: pointer;
  font: inherit; font-size: inherit; color: var(--primary); text-decoration: underline; text-underline-offset: 0.15em;
  text-decoration-color: color-mix(in srgb, var(--primary) 40%, transparent);
  transition: text-decoration-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-signin__link:hover { text-decoration-color: var(--primary); }
.fp-signin__link:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; border-radius: calc(var(--radius) / 3); }
.fp-signin__submit { width: 100%; padding-top: 0.875rem; padding-bottom: 0.875rem; }

.fp-signin__of { position: relative; display: flex; align-items: center; justify-content: center; }
.fp-signin__of-lijn { width: 100%; border-top: 1px solid var(--border); }
.fp-signin__of-tekst { position: absolute; padding: 0 1rem; font-size: 0.875rem; color: var(--muted-foreground); background: var(--background); }
.fp-signin__aanbieder {
  display: flex; align-items: center; justify-content: center; gap: 0.75rem; width: 100%;
  padding: 0.875rem 1rem;
  appearance: none; cursor: pointer; font: inherit; font-size: 0.9375rem;
  color: var(--foreground); background: transparent;
  border: 1px solid var(--border); border-radius: var(--radius);
  transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-signin__aanbieder:hover { background: var(--muted); }
.fp-signin__aanbieder[data-pressed] { transform: scale(0.98); }
.fp-signin__aanbieder:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-signin__aanbieder-icoon { display: flex; width: 1.25rem; height: 1.25rem; }
.fp-signin__nieuw { margin: 0; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }

.fp-signin__aside { display: none; flex: 1; position: relative; padding: 1rem; }
@media (min-width: 768px) { .fp-signin--met-aside .fp-signin__aside { display: block; } }