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>
descriptionstring—Helper text below the label, linked via aria-describedby
onChange(value: string) => void—Fired with the new string value (not the event)
onValidationChange(result: ValidationResult) => void—Fired whenever validation completes
showPasswordbooleanuncontrolledControlled show/hide state
onShowPasswordChange(show: boolean) => void—Fired 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>
classNamestring—Forwarded to the <input> itself (via spread)
...standard input props——name, 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
autoCompleteDefaults to "new-password" (signup/change-password) but respects an explicit value — pass autoComplete="current-password" for login forms or "off" to disable
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 !== undefined — not 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 ​

The live-region container is always mounted (screen readers only reliably announce content injected into a region that already existed in the DOM). When showValidationMessages is true and the validation result has feedback, the message list renders inside it:

html
<div role="status" aria-atomic="true">
  <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.