{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"password-strength","type":"registry:ui","title":"Password Strength","description":"Strength read segment by segment.","dependencies":["motion"],"categories":["input"],"docs":"https://www.interior.dev/docs/password-strength","files":[{"path":"registry/interior/password-strength.tsx","type":"registry:ui","target":"components/interior/password-strength.tsx","content":"\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst COMMON = /^(?:password|passw0rd|qwerty|letmein|welcome|admin|iloveyou|monkey|dragon|abc123|111111|123123|123456)/i;\nconst RUN = /(.)\\1{3,}/;\nconst RUN_UP = /(?:0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef|defg|qwer|wert|erty|asdf)/i;\nconst SYMBOL = /[!-/:-@[-`{-~]/;\n\nexport type PasswordRule = {\n  id: string;\n  label: string;\n  test: (value: string) => boolean;\n};\n\nexport type EvaluatedRule = PasswordRule & { met: boolean };\n\nexport type UsePasswordStrengthOptions = {\n  rules?: readonly PasswordRule[];\n  labels?: readonly string[];\n  announceDelay?: number;\n};\n\nexport type PasswordStrengthState = {\n  score: number;\n  max: number;\n  label: string;\n  rules: EvaluatedRule[];\n  guessable: boolean;\n  announcement: string;\n};\n\nexport const defaultPasswordRules: readonly PasswordRule[] = [\n  { id: \"length\", label: \"12 characters or more\", test: (v) => v.length >= 12 },\n  {\n    id: \"case\",\n    label: \"Upper and lower case\",\n    test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v),\n  },\n  { id: \"digit\", label: \"A number\", test: (v) => /\\d/.test(v) },\n  { id: \"symbol\", label: \"A symbol\", test: (v) => SYMBOL.test(v) },\n];\n\nconst defaultLabels = [\"Empty\", \"Weak\", \"Fair\", \"Good\", \"Strong\"] as const;\n\nexport function usePasswordStrength(\n  value: string,\n  {\n    rules = defaultPasswordRules,\n    labels = defaultLabels,\n    announceDelay = 700,\n  }: UsePasswordStrengthOptions = {},\n): PasswordStrengthState {\n  const state = useMemo(() => {\n    const evaluated = rules.map((rule) => ({ ...rule, met: rule.test(value) }));\n    const passed = evaluated.reduce((n, r) => n + (r.met ? 1 : 0), 0);\n    const guessable =\n      value.length > 0 && (COMMON.test(value) || RUN.test(value) || RUN_UP.test(value));\n\n    const score =\n      value.length === 0 ? 0 : guessable ? 1 : Math.min(rules.length, Math.max(1, passed));\n\n    const label = labels[Math.min(score, labels.length - 1)] ?? \"\";\n    const unmet = evaluated.filter((r) => !r.met);\n\n    const announcement =\n      value.length === 0\n        ? \"\"\n        : [\n            `Password strength ${label.toLowerCase()}.`,\n            guessable ? \"This is a commonly guessed pattern.\" : \"\",\n            unmet.length === 0\n              ? \"All requirements met.\"\n              : `Still needed: ${unmet.map((r) => r.label.toLowerCase()).join(\", \")}.`,\n          ]\n            .filter(Boolean)\n            .join(\" \");\n\n    return { score, max: rules.length, label, rules: evaluated, guessable, announcement };\n  }, [value, rules, labels]);\n\n  const [settled, setSettled] = useState(\"\");\n\n  useEffect(() => {\n    if (state.announcement === \"\") {\n      setSettled(\"\");\n      return;\n    }\n    const id = setTimeout(() => setSettled(state.announcement), announceDelay);\n    return () => clearTimeout(id);\n  }, [state.announcement, announceDelay]);\n\n  return { ...state, announcement: settled };\n}\n\nexport type PasswordStrengthProps = {\n  value: string;\n  rules?: readonly PasswordRule[];\n  labels?: readonly string[];\n  announceDelay?: number;\n  showRules?: boolean;\n  className?: string;\n};\n\nconst TONES = {\n  none: { bar: \"bg-stone-300 dark:bg-white/20\", text: \"text-stone-500 dark:text-stone-400\" },\n  danger: { bar: \"bg-red-500\", text: \"text-red-600 dark:text-red-400\" },\n  caution: { bar: \"bg-amber-500\", text: \"text-amber-600 dark:text-amber-400\" },\n  safe: { bar: \"bg-emerald-500\", text: \"text-emerald-600 dark:text-emerald-400\" },\n} as const;\n\nfunction toneFor(score: number, max: number) {\n  if (score === 0) return TONES.none;\n  const ratio = score / max;\n  if (ratio <= 0.34) return TONES.danger;\n  if (ratio <= 0.67) return TONES.caution;\n  return TONES.safe;\n}\n\nexport function PasswordStrength({\n  value,\n  rules = defaultPasswordRules,\n  labels = defaultLabels,\n  announceDelay = 700,\n  showRules = true,\n  className = \"\",\n}: PasswordStrengthProps) {\n  const {\n    score,\n    max,\n    label,\n    rules: evaluated,\n    guessable,\n    announcement,\n  } = usePasswordStrength(value, { rules, labels, announceDelay });\n  const reduced = useReducedMotion();\n  const tone = toneFor(score, max);\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        role=\"meter\"\n        aria-label=\"Password strength\"\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={score}\n        aria-valuetext={label}\n        className=\"grid gap-1.5\"\n        style={{ gridTemplateColumns: `repeat(${max}, minmax(0, 1fr))` }}\n      >\n        {Array.from({ length: max }, (_, i) => (\n          <div\n            key={i}\n            className=\"relative h-1.5 overflow-hidden rounded-[2px] bg-stone-200 dark:bg-white/12\"\n          >\n            <motion.span\n              className={`absolute inset-0 origin-left rounded-[2px] transition-colors duration-200 ${tone.bar}`}\n              initial={false}\n              animate={{ scaleX: i < score ? 1 : 0 }}\n              transition={\n                reduced ? INSTANT : { ...CELL, delay: i < score ? i * 0.03 : 0 }\n              }\n            />\n          </div>\n        ))}\n      </div>\n\n      <div className=\"mt-2 flex h-5 items-center justify-between gap-3\">\n        <span className=\"inline-grid text-[12.5px] font-medium leading-5\">\n          {labels.map((text, i) => (\n            <motion.span\n              key={text}\n              aria-hidden\n              className={`col-start-1 row-start-1 whitespace-nowrap transition-colors duration-200 ${tone.text}`}\n              initial={false}\n              animate={{ opacity: i === Math.min(score, labels.length - 1) ? 1 : 0 }}\n              transition={reduced ? INSTANT : CROSSFADE}\n            >\n              {text}\n            </motion.span>\n          ))}\n        </span>\n\n        <motion.span\n          aria-hidden\n          className=\"whitespace-nowrap text-[11.5px] leading-5 text-amber-600 dark:text-amber-400\"\n          initial={false}\n          animate={{ opacity: guessable ? 1 : 0 }}\n          transition={reduced ? INSTANT : CROSSFADE}\n        >\n          Commonly guessed\n        </motion.span>\n      </div>\n\n      {showRules && (\n        <ul className=\"mt-3 grid gap-1.5\">\n          {evaluated.map((rule) => (\n            <li key={rule.id} className=\"flex items-center gap-2\">\n              <span className=\"relative grid size-[14px] shrink-0 place-items-center rounded-[4px] border border-stone-200 text-white dark:border-white/[0.16] dark:text-stone-900\">\n                <motion.span\n                  className=\"absolute inset-0 rounded-[3px] bg-emerald-500\"\n                  initial={false}\n                  animate={{ opacity: rule.met ? 1 : 0 }}\n                  transition={reduced ? INSTANT : CROSSFADE}\n                />\n                <motion.svg\n                  viewBox=\"0 0 12 12\"\n                  fill=\"none\"\n                  aria-hidden\n                  className=\"relative size-[9px]\"\n                  initial={false}\n                  animate={{ opacity: rule.met ? 1 : 0, scale: rule.met ? 1 : 0.6 }}\n                  transition={reduced ? INSTANT : CELL}\n                >\n                  <path\n                    d=\"M2 6.2 4.7 8.9 10 3.3\"\n                    stroke=\"currentColor\"\n                    strokeWidth={1.9}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  />\n                </motion.svg>\n              </span>\n              <span\n                className={`text-[12.5px] leading-5 transition-colors duration-200 ${\n                  rule.met\n                    ? \"text-stone-700 dark:text-stone-200\"\n                    : \"text-stone-500 dark:text-stone-400\"\n                }`}\n              >\n                {rule.label}\n              </span>\n              <span className=\"sr-only\">{rule.met ? \"met\" : \"not met\"}</span>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </div>\n  );\n}\n"}]}