Skip to content

React Components API

The @sentinel-password/react-components package provides an accessible, headless PasswordInput component.

Installation

bash
npm install @sentinel-password/react-components

@sentinel-password/core is pulled in transitively as a regular dependency of @sentinel-password/react-components — you don't need to install it directly.

Peer Dependencies (^18.0.0 || ^19.0.0 each):

  • React 18 or 19
  • React DOM 18 or 19

Components

<PasswordInput />

A headless password input that runs validation through @sentinel-password/core and exposes the result via callback.

Features:

  • Designed for WCAG 2.1 AAA (see Accessibility guide for what the component covers vs. what's the consumer's responsibility)
  • Full ARIA support (aria-invalid, aria-describedby, aria-live, aria-pressed)
  • Keyboard navigation, including Escape to clear
  • Show/hide password toggle
  • Real-time validation with debouncing
  • Controlled and uncontrolled modes
  • Completely unstyled — bring your own CSS

Configure validation through validatorOptions

Pass policy and i18n options via the validatorOptions prop — they're forwarded to every internal validatePassword(...) call. Covers minLength, requireUppercase, personalInfo, plus the v1.2.0 i18n options messages and formatMessage. The component also re-validates the current value when the validatorOptions reference changes (so a locale switch refreshes feedback without the user editing the field). Memoize the object if it contains closures.

Props

PasswordInput extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'>. Most standard input attributes are forwarded to the underlying <input> — but the component reserves several for its own a11y and controlled-state logic (see Reserved props below).

Forwarded (consumer values reach the <input>): name, placeholder, className, style, autoFocus, required, minLength, maxLength, pattern, inputMode, onFocus, onBlur, data-*, etc.

typescript
interface PasswordInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
  // Required
  label: string

  // Optional content
  description?: string

  // Callbacks
  onChange?: (value: string) => void
  onValidationChange?: (result: ValidationResult) => void
  onShowPasswordChange?: (show: boolean) => void

  // Behavior
  showPassword?: boolean
  validateOnMount?: boolean   // Default: false
  validateOnChange?: boolean  // Default: true
  debounceMs?: number         // Default: 300

  // Visibility flags
  showValidationMessages?: boolean // Default: true
  showToggleButton?: boolean       // Default: true

  // Validator policy + i18n (forwarded to validatePassword)
  validatorOptions?: ValidatorOptions

  // Toggle button i18n
  toggleShowText?: string   // Default: 'Show'
  toggleHideText?: string   // Default: 'Hide'
  toggleShowLabel?: string  // Default: 'Show password' (aria-label)
  toggleHideLabel?: string  // Default: 'Hide password' (aria-label)

  // Class names (each targets a specific subtree)
  containerClassName?: string
  labelClassName?: string
  descriptionClassName?: string
  inputWrapperClassName?: string
  toggleButtonClassName?: string
  validationClassName?: string
}
PropTypeDefaultDescription
labelstring(required)Accessible label rendered as <label>
descriptionstringHelper text below the label, linked via aria-describedby
onChange(value: string) => voidFired with the new string value (not the event)
onValidationChange(result: ValidationResult) => voidFired whenever validation completes
showPasswordbooleanuncontrolledControlled show/hide state
onShowPasswordChange(show: boolean) => voidFired when the toggle button changes state
validateOnMountbooleanfalseValidate the initial value once on mount — but only if value/defaultValue is non-empty (an empty initial input silently skips). Mount validation goes through the same debounce path as normal validation, so the result lands ~debounceMs after mount; set debounceMs: 0 for synchronous mount validation.
validateOnChangebooleantrueValidate on every change (debounced)
debounceMsnumber300Debounce delay for validation. 0 validates synchronously.
showValidationMessagesbooleantrueRender the validation <ul>
showToggleButtonbooleantrueRender the show/hide button
validatorOptionsValidatorOptionsundefinedForwarded to every validatePassword(...) call inside the component. Covers minLength, requireUppercase, personalInfo, plus the i18n options messages / formatMessage from core@1.2.0. Nested rather than spread because React.InputHTMLAttributes already defines minLength / maxLength as HTML attributes. Memoize this object if it contains closures.
toggleShowTextstring'Show'Visible button text when the password is hidden
toggleHideTextstring'Hide'Visible button text when the password is visible
toggleShowLabelstring'Show password'aria-label when hidden
toggleHideLabelstring'Hide password'aria-label when visible
containerClassNamestring''Class on the outer <div> wrapper
labelClassNamestring''Class on <label>
descriptionClassNamestring''Class on the description <div>
inputWrapperClassNamestring''Class on the <input> + toggle wrapper
toggleButtonClassNamestring''Class on the show/hide <button>
validationClassNamestring''Class on the validation messages <div>
classNamestringForwarded to the <input> itself (via spread)
...standard input propsname, placeholder, autoFocus, required, etc. — see Reserved props for what doesn't pass through

Reserved Props

The component owns these and overrides any consumer value. Passing them is harmless but has no effect:

PropWhy it's reserved
idGenerated via useId() so the <label> can be associated with the input
onChangeReplaced with a (value: string) => void signature — see the onChange row above
aria-describedbyBuilt dynamically from description and validation message IDs
aria-invalidSet automatically when validation fails
autoCompleteHardcoded to "new-password" — appropriate for the password use case and prevents browsers from autofilling other credentials
refNot forwarded — the component is not wrapped in React.forwardRef
typeToggled internally between "password" and "text"; Omit-ed from the props type so you can't pass it

onKeyDown is wrapped, not overridden: the component handles Escape (clears the input) and then calls your handler for all keys, so user onKeyDown continues to receive events.

Controlled vs Uncontrolled

PasswordInput decides controlled mode by checking value !== undefinednot by checking whether onChange is also supplied. The rule:

What you passModeNotes
Neither value nor defaultValueUncontrolledInternal state starts as "".
defaultValue="…" onlyUncontrolledInternal state seeded from defaultValue.
value="…" + onChangeControlledStandard React pattern. You own the state.
value="…" without onChangeControlled — but brokenReact owns the value but you never update it, so the input is effectively read-only. defaultValue is silently ignored in this case too. Almost always a bug.

If you find yourself wanting defaultValue and value together, you actually want either fully controlled (drop defaultValue, lift state to your component) or fully uncontrolled (drop value, keep defaultValue).

Basic Usage

Simple

tsx
import { PasswordInput } from '@sentinel-password/react-components'

function SignupForm() {
  return (
    <form>
      <PasswordInput
        label="Create Password"
        description="At least 8 characters"
        onValidationChange={(result) => console.log(result.strength)}
      />
    </form>
  )
}

Controlled

tsx
import { PasswordInput } from '@sentinel-password/react-components'
import { useState } from 'react'
import type { ValidationResult } from '@sentinel-password/core'

function SignupForm() {
  const [password, setPassword] = useState('')
  const [result, setResult] = useState<ValidationResult | undefined>()

  return (
    <form>
      <PasswordInput
        label="Create Password"
        value={password}
        onChange={setPassword}
        onValidationChange={setResult}
      />

      <button type="submit" disabled={!result?.valid}>
        Create Account
      </button>
    </form>
  )
}

Uncontrolled With Default Value

PasswordInput does not currently forward refs. For uncontrolled usage, give the input a name (it flows through to the underlying <input>) and read the value from FormData on submit:

tsx
import { PasswordInput } from '@sentinel-password/react-components'

function ResetForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    console.log('Submitted:', data.get('password'))
  }

  return (
    <form onSubmit={handleSubmit}>
      <PasswordInput label="New Password" name="password" defaultValue="" />
      <button type="submit">Reset</button>
    </form>
  )
}

Refs are not forwarded

The component is a plain function component, not wrapped in React.forwardRef. Passing ref={...} will be silently ignored. Use the controlled pattern (value + onChange) when you need direct access to the value, or the FormData pattern above for uncontrolled forms.

Styling

The component renders only structural HTML and ARIA — every subtree gets its own *ClassName prop so you can attach styles selectively. There is no built-in CSS.

Plain CSS

tsx
<PasswordInput
  label="Password"
  containerClassName="password-field"
  labelClassName="password-label"
  inputWrapperClassName="password-wrapper"
  className="password-input"
  toggleButtonClassName="password-toggle"
  validationClassName="password-feedback"
/>
css
.password-field { margin-bottom: 1rem; }
.password-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
.password-wrapper { position: relative; }
.password-input {
  width: 100%;
  padding: 0.75rem;
  border: 2px solid #ddd;
  border-radius: 4px;
}
.password-input:focus { border-color: #3c8772; outline: none; }
.password-input[aria-invalid='true'] { border-color: #e53e3e; }
.password-toggle {
  position: absolute;
  right: 0.5rem;
  top: 50%;
  transform: translateY(-50%);
}
.password-feedback { margin-top: 0.5rem; color: #e53e3e; font-size: 0.875rem; }

Tailwind CSS

tsx
<PasswordInput
  label="Password"
  containerClassName="mb-4"
  labelClassName="block text-sm font-medium text-gray-700 mb-2"
  inputWrapperClassName="relative"
  className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
  toggleButtonClassName="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-sm"
  validationClassName="mt-2 text-sm text-red-600"
/>

Behavior Details

Validation

The component calls validatePassword(value, validatorOptions) from @sentinel-password/core. With validatorOptions omitted, the built-in defaults apply (minLength: 8, all check* flags on, no required character types, no personalInfo). Pass any subset of ValidatorOptions — including the v1.2.0 i18n options messages and formatMessage — to customize policy or localize the rendered messages. The returned ValidationResult is forwarded to onValidationChange and rendered as a list of messages inside the validationClassName container.

When the validatorOptions reference changes (e.g. a locale switch in the consumer), the component re-validates the current value automatically so the visible feedback refreshes without the user editing the field. Memoize the object in the consumer if it contains closures to avoid spurious re-validation.

If you'd rather drive validation yourself and render your own input, use usePasswordValidator instead.

Validation Messages

When showValidationMessages is true and the validation result has any feedback to show, the component renders:

html
<div role="alert" aria-live="polite">
  <ul>
    <li data-severity="warning">{feedback.suggestions[0]}</li>
    <!-- one <li data-severity="error"> per remaining suggestion -->
  </ul>
</div>

feedback.warning is always equal to feedback.suggestions[0] (the first failure, surfaced for prominent display), so the component renders it once with data-severity="warning" and the remaining suggestions with data-severity="error" — the warning is not rendered as a separate row.

A valid password produces an empty feedback.suggestions array and no feedback.warning, so the component renders nothing — the entire <ul> is skipped. In practice only warning and error severities are emitted today.

The data-severity attribute lets you style each message kind:

css
li[data-severity='warning'] { color: orange; }
li[data-severity='error']   { color: red; }

Want a "Password OK" message?

The component doesn't render a success row today (feedback.suggestions is empty on valid passwords). If you want a green confirmation, render it yourself from onValidationChange:

tsx
const [valid, setValid] = useState(false)

<PasswordInput
  label="Password"
  onValidationChange={(result) => setValid(result.valid)}
/>
{valid && <p style={{ color: 'green' }}>Password meets all requirements</p>}

ValidationMessageSeverity includes a 'success' variant for forward compatibility, but no built-in rendering path produces it today.

Keyboard Shortcuts

  • Tab / Shift+Tab: move between input, toggle button, and surrounding form controls
  • Escape: clear the input and reset validation (only when input has focus)
  • Space / Enter on the toggle button: show/hide the password

Show/Hide Toggle

The toggle is a real <button type="button"> with aria-pressed reflecting visibility. Hide it with showToggleButton={false} if you don't want it.

tsx
<PasswordInput label="Password" showToggleButton={false} />

You can also control visibility yourself:

tsx
const [show, setShow] = useState(false)

<PasswordInput
  label="Password"
  showPassword={show}
  onShowPasswordChange={setShow}
/>

TypeScript

typescript
import { PasswordInput } from '@sentinel-password/react-components'
import type {
  PasswordInputProps,
  ValidationMessage,
  ValidationMessageSeverity,
} from '@sentinel-password/react-components'

See Also

Released under the MIT License.