Browse Source

feat: add copy text option to input fields.

pull/573/head
Dan Ditomaso 1 year ago
parent
commit
2050b05d6a
  1. 28
      src/components/Form/FormInput.tsx
  2. 22
      src/components/Form/FormPasswordGenerator.tsx
  3. 2
      src/components/Form/FormWrapper.tsx
  4. 2
      src/components/PageComponents/Channel.tsx
  5. 9
      src/components/PageComponents/Config/Security/Security.tsx
  6. 42
      src/components/UI/Button.tsx
  7. 2
      src/components/UI/Command.tsx
  8. 37
      src/components/UI/Generator.tsx
  9. 164
      src/components/UI/Input.tsx
  10. 2
      src/components/UI/Sidebar/sidebarButton.tsx
  11. 51
      src/core/hooks/useCopyToClipboard.ts
  12. 66
      src/core/hooks/usePasswordVisibilityToggle.test.ts
  13. 20
      src/core/hooks/usePasswordVisibilityToggle.ts

28
src/components/Form/FormInput.tsx

@ -3,8 +3,6 @@ import type {
GenericFormElementProps, GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import type { LucideIcon } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useState } from "react"; import { useState } from "react";
import { useController, type FieldValues } from "react-hook-form"; import { useController, type FieldValues } from "react-hook-form";
@ -23,10 +21,8 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
currentValueLength?: number; currentValueLength?: number;
showCharacterCount?: boolean; showCharacterCount?: boolean;
}, },
action?: { showPasswordToggle?: boolean;
icon: LucideIcon; showCopyButton?: boolean;
onClick: () => void;
};
}; };
} }
@ -36,8 +32,6 @@ export function GenericInput<T extends FieldValues>({
field, field,
}: GenericFormElementProps<T, InputFieldProps<T>>) { }: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {}; const { fieldLength, ...restProperties } = field.properties || {};
const [passwordShown, setPasswordShown] = useState(false);
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0); const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
const { field: controllerField } = useController({ const { field: controllerField } = useController({
@ -45,10 +39,6 @@ export function GenericInput<T extends FieldValues>({
control, control,
}); });
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
@ -66,25 +56,19 @@ export function GenericInput<T extends FieldValues>({
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
type={field.type === "password" && passwordShown ? "text" : field.type} type={field.type}
action={
field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined
}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value} value={field.type === "number" ? String(controllerField.value) : controllerField.value}
id={field.name} id={field.name}
onChange={handleInputChange} onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
{...restProperties} {...restProperties}
disabled={disabled} disabled={disabled}
/> />
{fieldLength?.showCharacterCount && fieldLength?.max && ( {fieldLength?.showCharacterCount && fieldLength?.max && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400"> <div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max} {currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div> </div>
)} )}

22
src/components/Form/FormPasswordGenerator.tsx

@ -4,10 +4,9 @@ import type {
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx"; import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx"; import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> { export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator"; type: "passwordGenerator";
@ -15,7 +14,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean; hide?: boolean;
bits?: { text: string; value: string; key: string }[]; bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number; devicePSKBitCount: number;
inputChange: ChangeEventHandler; inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
selectChange: (event: string) => void; selectChange: (event: string) => void;
actionButtons: { actionButtons: {
text: string; text: string;
@ -23,6 +22,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
variant: ButtonVariant; variant: ButtonVariant;
className?: string; className?: string;
}[]; }[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
} }
export function PasswordGenerator<T extends FieldValues>({ export function PasswordGenerator<T extends FieldValues>({
@ -30,10 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
field, field,
disabled, disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) { }: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false); const { isVisible } = usePasswordVisibilityToggle()
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
return ( return (
<Controller <Controller
@ -41,14 +39,8 @@ export function PasswordGenerator<T extends FieldValues>({
control={control} control={control}
render={({ field: { value, ...rest } }) => ( render={({ field: { value, ...rest } }) => (
<Generator <Generator
type={field.hide && !passwordShown ? "password" : "text"} type={field.hide && !isVisible ? "password" : "text"}
id={field.id} id={field.id}
action={field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
devicePSKBitCount={field.devicePSKBitCount} devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits} bits={field.bits}
inputChange={field.inputChange} inputChange={field.inputChange}
@ -56,6 +48,8 @@ export function PasswordGenerator<T extends FieldValues>({
value={value} value={value}
variant={field.validationText ? "invalid" : "default"} variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons} actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}

2
src/components/Form/FormWrapper.tsx

@ -24,7 +24,7 @@ export const FieldWrapper = ({
<Label htmlFor={fieldName}>{label}</Label> <Label htmlFor={fieldName}>{label}</Label>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="max-w-lg"> <div className="max-w-lg">
<p className="text-sm text-slate-500">{description}</p> <p className="text-sm text-slate-400">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500"> <p hidden={valid ?? true} className="text-sm text-red-500">
{validationText} {validationText}
</p> </p>

2
src/components/PageComponents/Channel.tsx

@ -143,6 +143,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
hide: true, hide: true,
properties: { properties: {
value: pass, value: pass,
showPasswordToggle: true,
showCopyButton: true,
}, },
}, },
{ {

9
src/components/PageComponents/Config/Security/Security.tsx

@ -195,11 +195,8 @@ export const Security = () => {
], ],
properties: { properties: {
value: state.privateKey, value: state.privateKey,
action: { showCopyButton: true,
icon: state.privateKeyVisible ? EyeOff : Eye, showPasswordToggle: true,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
}, },
}, },
{ {
@ -211,6 +208,7 @@ export const Security = () => {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key", "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: { properties: {
value: state.publicKey, value: state.publicKey,
showCopyButton: true,
}, },
}, },
], ],
@ -271,6 +269,7 @@ export const Security = () => {
], ],
properties: { properties: {
value: state.adminKey, value: state.adminKey,
showCopyButton: true,
action: { action: {
icon: state.adminKeyVisible ? EyeOff : Eye, icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () => onClick: () =>

42
src/components/UI/Button.tsx

@ -1,10 +1,9 @@
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 cursor-pointer", "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer",
{ {
variants: { variants: {
variant: { variant: {
@ -27,6 +26,7 @@ const buttonVariants = cva(
default: "h-10 py-2 px-4", default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md", sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md", lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -39,26 +39,50 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps export interface ButtonProps
extends extends React.ButtonHTMLAttributes<HTMLButtonElement>,
React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> { } icon?: React.ReactNode;
iconAlignment?: "left" | "right";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, disabled, ...props }, ref) => { (
{
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
},
ref,
) => {
return ( return (
<button <button
type="button" type="button"
className={cn( className={cn(
buttonVariants({ variant, size, className }), buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled } { "cursor-not-allowed": disabled },
"inline-flex items-center"
)} )}
ref={ref} ref={ref}
disabled={disabled} disabled={disabled}
{...props} {...props}
/> >
{icon && iconAlignment === "left" && (
<span className={cn({ "mr-2": !!children })}>{icon}</span>
)}
{children}
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
); );
}, },
); );
Button.displayName = "Button"; Button.displayName = "Button";
export { Button, buttonVariants }; export { Button, buttonVariants };

2
src/components/UI/Command.tsx

@ -41,7 +41,7 @@ const CommandInput = React.forwardRef<
className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700" className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700"
cmdk-input-wrapper="" cmdk-input-wrapper=""
> >
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-white" />
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn( className={cn(

37
src/components/UI/Generator.tsx

@ -9,7 +9,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.tsx"; } from "@components/UI/Select.tsx";
import type { LucideIcon } from "lucide-react";
export interface ActionButton {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[]
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> { export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
type: "text" | "password"; type: "text" | "password";
@ -17,19 +23,12 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
value: string; value: string;
id: string; id: string;
variant: "default" | "invalid"; variant: "default" | "invalid";
actionButtons: { actionButtons: ActionButton[];
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
bits?: { text: string; value: string; key: string }[]; bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void; selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; inputChange: (event: React.ChangeEventHandler<HTMLInputElement> | undefined) => void;
action?: { showPasswordToggle?: boolean;
icon: LucideIcon; showCopyButton?: boolean;
onClick: () => void;
};
disabled?: boolean; disabled?: boolean;
} }
@ -50,8 +49,9 @@ const Generator =
], ],
selectChange, selectChange,
inputChange, inputChange,
action,
disabled, disabled,
showPasswordToggle,
showCopyButton,
...props ...props
}: GeneratorProps }: GeneratorProps
) => { ) => {
@ -78,27 +78,28 @@ const Generator =
variant={variant} variant={variant}
value={value} value={value}
onChange={inputChange} onChange={inputChange}
action={action}
disabled={disabled} disabled={disabled}
ref={inputRef} ref={inputRef}
showCopyButton={showCopyButton}
showPasswordToggle={showPasswordToggle}
/> />
<Select <Select
value={devicePSKBitCount?.toString()} value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)} onValueChange={(e) => selectChange(e)}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger> <SelectTrigger className="w-36 ml-2">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-36">
{bits.map(({ text, value, key }) => ( {bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}> <SelectItem key={key} value={value} className="w-36">
{text} {text}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex ml-4 space-x-4"> <div className="flex ml-2 space-x-2">
{actionButtons?.map(({ text, onClick, variant, className }) => ( {actionButtons?.map(({ text, onClick, variant, className }) => (
<Button <Button
key={text} key={text}

164
src/components/UI/Input.tsx

@ -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 };

2
src/components/UI/Sidebar/sidebarButton.tsx

@ -6,7 +6,7 @@ export interface SidebarButtonProps {
count?: number; count?: number;
active?: boolean; active?: boolean;
Icon?: LucideIcon; Icon?: LucideIcon;
children: React.ReactNode; children?: React.ReactNode;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
} }

51
src/core/hooks/useCopyToClipboard.ts

@ -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 };
}

66
src/core/hooks/usePasswordVisibilityToggle.test.ts

@ -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);
});
});

20
src/core/hooks/usePasswordVisibilityToggle.ts

@ -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…
Cancel
Save