13 changed files with 339 additions and 108 deletions
@ -1,73 +1,163 @@ |
|||
import * as React from "react"; |
|||
|
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import { cva, type VariantProps } from "class-variance-authority"; |
|||
import type { LucideIcon } from "lucide-react"; |
|||
import { Check, Copy, Eye, EyeOff, type LucideIcon } from "lucide-react"; |
|||
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; |
|||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; |
|||
|
|||
const inputVariants = cva( |
|||
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:open-dialog:text-slate-900", |
|||
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "border-slate-300 dark:border-slate-700", |
|||
invalid: "border-red-500 dark:border-red-500", |
|||
invalid: |
|||
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
}, |
|||
} |
|||
); |
|||
|
|||
type InputActionType = { |
|||
id: string; |
|||
icon: LucideIcon; |
|||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; |
|||
ariaLabel: string; |
|||
tooltip?: string; |
|||
}; |
|||
|
|||
export interface InputProps |
|||
extends |
|||
React.InputHTMLAttributes<HTMLInputElement>, |
|||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">, |
|||
VariantProps<typeof inputVariants> { |
|||
prefix?: string; |
|||
suffix?: string; |
|||
action?: { |
|||
icon: LucideIcon; |
|||
onClick: () => void; |
|||
}; |
|||
prefix?: React.ReactNode; |
|||
suffix?: React.ReactNode; |
|||
showPasswordToggle?: boolean; |
|||
showCopyButton?: boolean; |
|||
containerClassName?: string; |
|||
} |
|||
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, value, variant, prefix, suffix, action, ...props }, ref) => { |
|||
( |
|||
{ |
|||
className, |
|||
containerClassName, |
|||
variant, |
|||
type = "text", |
|||
prefix, |
|||
suffix, |
|||
showPasswordToggle, |
|||
showCopyButton, |
|||
value, |
|||
...props |
|||
}, |
|||
ref |
|||
) => { |
|||
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); |
|||
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); |
|||
|
|||
const actions: InputActionType[] = []; |
|||
|
|||
if (showPasswordToggle && type === "password") { |
|||
actions.push({ |
|||
id: "toggle-visibility", |
|||
icon: isVisible ? EyeOff : Eye, |
|||
onClick: (e) => { |
|||
e.stopPropagation(); |
|||
toggleVisibility(); |
|||
}, |
|||
ariaLabel: isVisible ? "Hide password" : "Show password", |
|||
tooltip: isVisible ? "Hide password" : "Show password", |
|||
}); |
|||
} |
|||
if (showCopyButton) { |
|||
actions.push({ |
|||
id: "copy-value", |
|||
icon: isCopied ? Check : Copy, |
|||
onClick: (e) => { |
|||
e.stopPropagation(); |
|||
if (value !== undefined && value !== null) { |
|||
copy(String(value)); |
|||
} |
|||
}, |
|||
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard", |
|||
tooltip: isCopied ? "Copied!" : "Copy to clipboard", |
|||
}); |
|||
} |
|||
|
|||
const inputType = showPasswordToggle ? (isVisible ? "text" : "password") : type; |
|||
|
|||
const hasPrefix = !!prefix; |
|||
const hasSuffix = !!suffix; |
|||
const hasActions = actions.length > 0; |
|||
|
|||
const inputClassName = cn( |
|||
inputVariants({ variant }), |
|||
hasPrefix && "rounded-l-none", |
|||
(hasSuffix || hasActions) && "rounded-r-none border-r-0", |
|||
className |
|||
); |
|||
|
|||
|
|||
return ( |
|||
<div className="relative w-full"> |
|||
<div className={cn("relative flex w-full items-stretch", containerClassName)}> |
|||
{prefix && ( |
|||
<label className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600"> |
|||
<span className="inline-flex items-center rounded-l-md border border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300"> |
|||
{prefix} |
|||
</label> |
|||
</span> |
|||
)} |
|||
|
|||
<input |
|||
className={cn( |
|||
action && "pr-8", |
|||
inputVariants({ variant }), |
|||
className, |
|||
)} |
|||
value={value} |
|||
type={inputType === "password" && isVisible ? "text" : inputType} |
|||
className={inputClassName} |
|||
ref={ref} |
|||
value={value} |
|||
{...props} |
|||
/> |
|||
{suffix && ( |
|||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-slate-500 dark:text-slate-900"> |
|||
<span className="text-slate-100/40 sm:text-sm">{suffix}</span> |
|||
|
|||
{(hasSuffix || hasActions) && ( |
|||
<div className={cn( |
|||
"flex items-stretch", |
|||
!hasSuffix && hasActions && "border-y border-r border-slate-300 dark:border-slate-700 rounded-r-md" |
|||
)}> |
|||
{suffix && ( |
|||
<span className={cn( |
|||
"inline-flex items-center border border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300", |
|||
!hasActions && "rounded-r-md" |
|||
)}> |
|||
{suffix} |
|||
</span> |
|||
)} |
|||
{actions.length > 0 && ( |
|||
<div className={cn( |
|||
"flex h-full items-center divide-x divide-slate-300 dark:divide-slate-700", |
|||
!hasSuffix && "border-l border-slate-300 dark:border-slate-700" |
|||
)}> |
|||
{actions?.map((action) => ( |
|||
<button |
|||
key={action.id} |
|||
type="button" |
|||
className={cn( |
|||
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500", |
|||
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500" |
|||
)} |
|||
onClick={action.onClick} |
|||
aria-label={action.ariaLabel} |
|||
title={action.tooltip || action.ariaLabel} |
|||
> |
|||
<action.icon size={18} aria-hidden="true" /> |
|||
</button> |
|||
))} |
|||
</div> |
|||
)} |
|||
</div> |
|||
)} |
|||
{action && ( |
|||
<button |
|||
type="button" |
|||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-400 focus:outline-hidden " |
|||
onClick={action.onClick} |
|||
> |
|||
<action.icon size={20} /> |
|||
</button> |
|||
)} |
|||
</div> |
|||
); |
|||
}, |
|||
} |
|||
); |
|||
Input.displayName = "Input"; |
|||
|
|||
export { Input, inputVariants }; |
|||
export { Input, inputVariants }; |
|||
@ -0,0 +1,51 @@ |
|||
import { useCallback, useEffect, useRef, useState } from "react"; |
|||
|
|||
interface UseCopyToClipboardProps { |
|||
timeout?: number; |
|||
} |
|||
|
|||
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) { |
|||
const [isCopied, setIsCopied] = useState<boolean>(false); |
|||
const timeoutRef = useRef<number | null>(null); |
|||
|
|||
useEffect(() => { |
|||
return () => { |
|||
if (timeoutRef.current) { |
|||
globalThis.clearTimeout(timeoutRef.current); |
|||
} |
|||
}; |
|||
}, []); |
|||
|
|||
const copy = useCallback( |
|||
async (text: string) => { |
|||
if (!navigator?.clipboard) { |
|||
console.warn('Clipboard API not available'); |
|||
setIsCopied(false); |
|||
return false; |
|||
} |
|||
|
|||
if (timeoutRef.current) { |
|||
globalThis.clearTimeout(timeoutRef.current); |
|||
} |
|||
|
|||
try { |
|||
await navigator.clipboard.writeText(text); |
|||
setIsCopied(true); |
|||
|
|||
timeoutRef.current = globalThis.setTimeout(() => { |
|||
setIsCopied(false); |
|||
timeoutRef.current = null; |
|||
}, timeout); |
|||
|
|||
return true; |
|||
} catch (error) { |
|||
console.error('Failed to copy text to clipboard:', error); |
|||
setIsCopied(false); |
|||
return false; |
|||
} |
|||
}, |
|||
[timeout] |
|||
); |
|||
|
|||
return { isCopied, copy }; |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
import { renderHook, act } from '@testing-library/react'; |
|||
import { describe, it, expect } from 'vitest'; |
|||
import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts'; |
|||
|
|||
describe('usePasswordVisibilityToggle Hook', () => { |
|||
it('should initialize with visibility set to false by default', () => { |
|||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|||
expect(result.current.isVisible).toBe(false); |
|||
expect(typeof result.current.toggleVisibility).toBe('function'); |
|||
}); |
|||
|
|||
it('should initialize with visibility set to true if initialVisible is true', () => { |
|||
const { result } = renderHook(() => |
|||
usePasswordVisibilityToggle({ initialVisible: true }) |
|||
); |
|||
expect(result.current.isVisible).toBe(true); |
|||
}); |
|||
|
|||
it('should toggle visibility from false to true when toggleVisibility is called', () => { |
|||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|||
expect(result.current.isVisible).toBe(false); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(true); |
|||
}); |
|||
|
|||
it('should toggle visibility from true to false when toggleVisibility is called', () => { |
|||
const { result } = renderHook(() => |
|||
usePasswordVisibilityToggle({ initialVisible: true }) |
|||
); |
|||
expect(result.current.isVisible).toBe(true); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(false); |
|||
}); |
|||
|
|||
it('should toggle visibility correctly multiple times', () => { |
|||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|||
expect(result.current.isVisible).toBe(false); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(true); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(false); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(true); |
|||
}); |
|||
|
|||
it('should return a stable toggleVisibility function reference (due to useCallback)', () => { |
|||
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle()); |
|||
const initialToggleFunc = result.current.toggleVisibility; |
|||
rerender(); |
|||
expect(result.current.toggleVisibility).toBe(initialToggleFunc); |
|||
act(() => { |
|||
result.current.toggleVisibility(); |
|||
}); |
|||
expect(result.current.isVisible).toBe(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,20 @@ |
|||
import { useState, useCallback } from 'react'; |
|||
|
|||
interface UsePasswordVisibilityToggleProps { |
|||
initialVisible?: boolean; |
|||
} |
|||
/** |
|||
* Manages the state for toggling password visibility. |
|||
* |
|||
* @param {boolean} [options.initialVisible=false] |
|||
* @returns {{isVisible: boolean, toggleVisibility: () => void}} |
|||
*/ |
|||
export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) { |
|||
const [isVisible, setIsVisible] = useState<boolean>(initialVisible); |
|||
|
|||
const toggleVisibility = useCallback(() => { |
|||
setIsVisible(prev => !prev); |
|||
}, []); |
|||
|
|||
return { isVisible, toggleVisibility }; |
|||
} |
|||
Loading…
Reference in new issue