13 changed files with 339 additions and 108 deletions
@ -1,73 +1,163 @@ |
|||||
import * as React from "react"; |
import * as React from "react"; |
||||
|
|
||||
import { cn } from "@core/utils/cn.ts"; |
import { cn } from "@core/utils/cn.ts"; |
||||
import { cva, type VariantProps } from "class-variance-authority"; |
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( |
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: { |
variants: { |
||||
variant: { |
variant: { |
||||
default: "border-slate-300 dark:border-slate-700", |
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: { |
defaultVariants: { |
||||
variant: "default", |
variant: "default", |
||||
}, |
}, |
||||
}, |
} |
||||
); |
); |
||||
|
|
||||
|
type InputActionType = { |
||||
|
id: string; |
||||
|
icon: LucideIcon; |
||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; |
||||
|
ariaLabel: string; |
||||
|
tooltip?: string; |
||||
|
}; |
||||
|
|
||||
export interface InputProps |
export interface InputProps |
||||
extends |
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">, |
||||
React.InputHTMLAttributes<HTMLInputElement>, |
|
||||
VariantProps<typeof inputVariants> { |
VariantProps<typeof inputVariants> { |
||||
prefix?: string; |
prefix?: React.ReactNode; |
||||
suffix?: string; |
suffix?: React.ReactNode; |
||||
action?: { |
showPasswordToggle?: boolean; |
||||
icon: LucideIcon; |
showCopyButton?: boolean; |
||||
onClick: () => void; |
containerClassName?: string; |
||||
}; |
|
||||
} |
} |
||||
|
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
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 ( |
return ( |
||||
<div className="relative w-full"> |
<div className={cn("relative flex w-full items-stretch", containerClassName)}> |
||||
{prefix && ( |
{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} |
{prefix} |
||||
</label> |
</span> |
||||
)} |
)} |
||||
|
|
||||
<input |
<input |
||||
className={cn( |
type={inputType === "password" && isVisible ? "text" : inputType} |
||||
action && "pr-8", |
className={inputClassName} |
||||
inputVariants({ variant }), |
|
||||
className, |
|
||||
)} |
|
||||
value={value} |
|
||||
ref={ref} |
ref={ref} |
||||
|
value={value} |
||||
{...props} |
{...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"> |
{(hasSuffix || hasActions) && ( |
||||
<span className="text-slate-100/40 sm:text-sm">{suffix}</span> |
<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> |
</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> |
</div> |
||||
); |
); |
||||
}, |
} |
||||
); |
); |
||||
Input.displayName = "Input"; |
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