sign-in
FC-0044sign-inblockv1.0.0Inlogpagina: 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.
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; } }