Input

A field you pass props to. The password reveal is free, and a generator, a strength meter and a breach check are one prop each.

In React it is a component you pass props to. A type="password" field gets its reveal toggle for free, because a password the visitor cannot read back is the most common reason a sign-in fails on the first try. Everything else - the generator, the strength meter, the breach check - is one prop, and off until you ask for it.

Customize

Nothing here is on until you ask for it, so this panel starts where the component does. Type into it, generate one, reveal it - the preview is the real field and the code below is generated from the same values.

Usage

import { Input } from "@enigmax/primitives/react";

export function SignIn() {
    const [password, setPassword] = useState("");

    return (
        <Input
            type="password"
            autoComplete="current-password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
        />
    );
}

Installation

Adds the package and prints the import to use. Append --copy to write the source into your project instead.

enigma add input

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

Generating a password

Off by default. Switch it on for a registration or change-password form, where the visitor has no password yet, and leave it off on a sign-in, where offering to invent one is noise.

<Input type="password" autoComplete="new-password" generate={{ length: 24 }} />

The value is written the way a keystroke writes it, so it reaches a controlled field, an uncontrolled one and a form library alike - onChange fires exactly as if it had been typed. Characters come from crypto.getRandomValues, drawn by rejection rather than % alphabet.length, which is biased. Where there is no CSPRNG it throws instead of falling back, because a generator that quietly produces predictable passwords is worse than one that refuses.

Prop Default
generate false true, or GeneratePasswordOptions
length 20 Long beats clever: length is the only term that scales
excludeAmbiguous false Drops I l 1 O 0, for a password that gets typed by hand
revealOnGenerate true A password nobody can read is one nobody can write down
copyOnGenerate false The clipboard is shared with every app on the machine

Strength

strength renders the meter under the field: five bars, a label, and the top warning.

<Input
    type="password"
    autoComplete="new-password"
    strength={{ userInputs: [email, name] }}
    onStrengthChange={(report) => setScore(report.score)}
/>

userInputs is the check no character-class rule makes. Ada@example.com1! has four character classes and sixteen characters, passes every policy ever written, and is the first thing anyone looking at the sign-up form would try.

The component picks no colours: it puts data-score on the root and data-filled on each segment, and the recipe above turns that into red through green. The score itself is an estimate and the bands are a convention rather than a measurement - swap the estimator for zxcvbn where the number has to mean something.

Has it leaked?

breach takes a checker; it is a prop rather than a built-in because it makes a network request, and that is not a decision a field should take on its own. enigma add password-breach gives you one that never sends the password anywhere.

import { checkPasswordBreach } from "@enigmax/utils";

<Input
    type="password"
    autoComplete="new-password"
    breach={checkPasswordBreach}
    onBreachChange={(state) => setBreached(state.status === "breached")}
/>

It is debounced and aborted on the next keystroke, so an answer about a password three characters old can never land on the current one. The field reports data-breached and a count, and renders no message: a breach is a warning on one form, a hard block on another, and each already renders that its own way.

With Zod, validated as you type

The whole thing, the way it actually gets used: one schema, checked on every keystroke, with the field’s own state folded in.

"use client";

import { z } from "zod";
import { useState } from "react";
import { Input } from "@enigmax/primitives/react";
import { checkPasswordBreach } from "@enigmax/utils";

/**
 * The schema is the whole rule, and it lives at the OBJECT level - a password field on its
 * own cannot see the email three rows up, which is what makes `ada@example.com1!` pass a
 * field-level check and fail a real one.
 */
const schema = z.object({
    email: z.string().trim().toLowerCase().email("That does not look like an email address."),
    password: z.string().min(12, "At least 12 characters.")
}).refine(
    ({ email, password }) => !password.toLowerCase().includes(email.split("@")[0].toLowerCase()),
    { path: ["password"], message: "Your password cannot contain your email address." }
);

export function Register() {
    const [values, setValues] = useState({ email: "", password: "" });
    const [touched, setTouched] = useState<Record<string, boolean>>({});
    const [breached, setBreached] = useState(false);

    // Parsed on every render, so the message appears as the rule starts passing rather
    // than on submit. Only shown for fields the visitor has actually left.
    const result = schema.safeParse(values);
    const errors = result.success ? {} : z.flattenError(result.error).fieldErrors;
    const errorFor = (field: keyof typeof values) => (touched[field] ? errors[field]?.[0] : undefined);

    return (
        <form
            noValidate
            onSubmit={(event) => {
                event.preventDefault();
                setTouched({ email: true, password: true });
                if (!result.success || breached) return;
                // ...
            }}
        >
            <Input
                type="email"
                autoComplete="email"
                value={values.email}
                onChange={(event) => setValues({ ...values, email: event.target.value })}
                onBlur={() => setTouched({ ...touched, email: true })}
                aria-invalid={Boolean(errorFor("email"))}
            >
                {errorFor("email") && <p className="error">{errorFor("email")}</p>}
            </Input>

            <Input
                type="password"
                autoComplete="new-password"
                value={values.password}
                onChange={(event) => setValues({ ...values, password: event.target.value })}
                onBlur={() => setTouched({ ...touched, password: true })}
                aria-invalid={Boolean(errorFor("password"))}
                generate={{ length: 20 }}
                strength={{ userInputs: [values.email] }}
                breach={checkPasswordBreach}
                onBreachChange={(state) => setBreached(state.status === "breached")}
            >
                {errorFor("password") && <p className="error">{errorFor("password")}</p>}
                {breached && <p className="error">This password has appeared in a breach. Please pick another.</p>}
            </Input>

            <button type="submit" disabled={!result.success || breached}>Create account</button>
        </form>
    );
}

Two things worth copying from it. The same schema runs again on the server, because a client-side check is a convenience and never a guarantee. And the breach result is kept beside the schema rather than inside it: it arrives asynchronously, so folding it into a synchronous parse would either block the form on a network request or lie about the state while one is in flight.

Replacing a button, or adding your own

The reveal and the generator are ordinary actions. Passing one with the same name replaces it; any other name is added.

<Input
    type="password"
    actions={[
        { name: "reveal", label: "Show", icon: <MyEye />, pressed: shown, onSelect: toggle },
        { name: "caps", label: "Caps lock is on", icon: <MyCaps />, visible: capsOn, onSelect: () => {} }
    ]}
/>

position="start" moves them to the other end of the field. An action whose visible is false is removed from the DOM rather than hidden - the hidden attribute works through a UA display: none rule, and any styling you write for these buttons beats it.

Styling hooks

Nothing here ships a colour.

Attribute On
[data-enigma-input-root] The wrapper - carries data-revealed, data-breached, data-score
[data-enigma-input-field] The row holding the field and its buttons
[data-enigma-input] The <input> itself
[data-enigma-input-actions] The button container - carries data-position
[data-enigma-input-action="reveal"] One button, by name. aria-pressed tracks the state
[data-enigma-password-strength] The meter - data-empty while there is nothing to score
[data-enigma-password-strength-segment] One bar. data-filled when it is lit

In Tailwind, the parts you cannot reach with a className take one through classNames, and a segment colours itself from the score on the root:

<Input
    wrapperProps={{ className: "group/field grid gap-1.5" }}
    classNames={{
        action: "grid h-7 w-7 place-items-center rounded-md text-neutral-400 hover:text-neutral-100",
        strength: { segment: "h-1 rounded-full bg-neutral-800 group-data-[score=4]/field:data-[filled]:bg-green-600" }
    }}
/>