password-input.tsx 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. "use client";
  2. /**
  3. * Password field with show/hide eye control.
  4. * Use this for every password input in the portal.
  5. */
  6. import { useState, type ReactNode } from "react";
  7. import { Input } from "@/components/ui/input";
  8. import { cn } from "@/lib/utils";
  9. export function PasswordInput({
  10. id,
  11. label,
  12. value,
  13. onChange,
  14. placeholder,
  15. autoComplete = "current-password",
  16. disabled,
  17. className,
  18. inputClassName,
  19. onEnter,
  20. hideLabel,
  21. }: {
  22. id: string;
  23. /** Visible label; use empty + hideLabel for icon-only contexts */
  24. label?: string;
  25. value: string;
  26. onChange: (value: string) => void;
  27. placeholder?: string;
  28. autoComplete?: string;
  29. disabled?: boolean;
  30. className?: string;
  31. inputClassName?: string;
  32. onEnter?: () => void;
  33. hideLabel?: boolean;
  34. }) {
  35. const [show, setShow] = useState(false);
  36. const accessibleName = label || placeholder || "Password";
  37. return (
  38. <div className={cn("space-y-2", className)}>
  39. {label && !hideLabel ? (
  40. <label htmlFor={id} className="text-sm font-medium">
  41. {label}
  42. </label>
  43. ) : (
  44. <label htmlFor={id} className="sr-only">
  45. {accessibleName}
  46. </label>
  47. )}
  48. <div className="relative">
  49. <Input
  50. id={id}
  51. type={show ? "text" : "password"}
  52. autoComplete={autoComplete}
  53. value={value}
  54. disabled={disabled}
  55. onChange={(e) => onChange(e.target.value)}
  56. placeholder={placeholder}
  57. className={cn("pr-11", inputClassName)}
  58. onKeyDown={(e) => {
  59. if (e.key === "Enter") onEnter?.();
  60. }}
  61. />
  62. <button
  63. type="button"
  64. disabled={disabled}
  65. onClick={() => setShow((v) => !v)}
  66. className="absolute inset-y-0 right-0 flex min-w-11 items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
  67. aria-label={show ? `Hide ${accessibleName}` : `Show ${accessibleName}`}
  68. aria-pressed={show}
  69. title={show ? "Hide password" : "Show password"}
  70. >
  71. {show ? <EyeOffIcon /> : <EyeIcon />}
  72. </button>
  73. </div>
  74. </div>
  75. );
  76. }
  77. /** Alias used by mavi screens */
  78. export function PasswordField(
  79. props: Parameters<typeof PasswordInput>[0],
  80. ): ReactNode {
  81. return <PasswordInput {...props} />;
  82. }
  83. function EyeIcon() {
  84. return (
  85. <svg
  86. width="18"
  87. height="18"
  88. viewBox="0 0 24 24"
  89. fill="none"
  90. stroke="currentColor"
  91. strokeWidth="2"
  92. strokeLinecap="round"
  93. strokeLinejoin="round"
  94. aria-hidden
  95. >
  96. <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
  97. <circle cx="12" cy="12" r="3" />
  98. </svg>
  99. );
  100. }
  101. function EyeOffIcon() {
  102. return (
  103. <svg
  104. width="18"
  105. height="18"
  106. viewBox="0 0 24 24"
  107. fill="none"
  108. stroke="currentColor"
  109. strokeWidth="2"
  110. strokeLinecap="round"
  111. strokeLinejoin="round"
  112. aria-hidden
  113. >
  114. <path d="M10.7 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a18.5 18.5 0 0 1-2.2 3.2" />
  115. <path d="M6.6 6.6C3.9 8.3 2 12 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.5" />
  116. <path d="M14.1 14.1A3 3 0 0 1 9.9 9.9" />
  117. <path d="M2 2l20 20" />
  118. </svg>
  119. );
  120. }