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

22
src/components/Form/FormPasswordGenerator.tsx

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

2
src/components/Form/FormWrapper.tsx

@ -24,7 +24,7 @@ export const FieldWrapper = ({
<Label htmlFor={fieldName}>{label}</Label>
<div className="sm:col-span-2">
<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">
{validationText}
</p>

2
src/components/PageComponents/Channel.tsx

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

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

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

42
src/components/UI/Button.tsx

@ -1,10 +1,9 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
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: {
variant: {
@ -27,6 +26,7 @@ const buttonVariants = cva(
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
@ -39,26 +39,50 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { }
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
iconAlignment?: "left" | "right";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, disabled, ...props }, ref) => {
(
{
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
},
ref,
) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled }
{ "cursor-not-allowed": disabled },
"inline-flex items-center"
)}
ref={ref}
disabled={disabled}
{...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";
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"
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
ref={ref}
className={cn(

37
src/components/UI/Generator.tsx

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

164
src/components/UI/Input.tsx

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

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

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