Files
TaxHacker_s23/components/forms/simple.tsx
Artem Sushchev 24a2cfc729 feat: Accessibility Fixes for Custom Field Forms (#48)
* fix: add IDs and ARIA labels to custom field forms

- Add id propagation to FormInput/FormTextarea based on name prop for proper label association
- Enhance FormSelect with hidden input for form submission, aria-labelledby for labeling, and controlled/uncontrolled state management
- Add aria-label attributes to inline inputs/selects/checkboxes in CrudTable settings editor

These changes improve accessibility for screen readers and ensure custom field forms are fully navigable via keyboard. Also critical for AI browsers like Comet that rely on semantic HTML/ARIA for form parsing and automation.

* fix: add aria-labels to CRUD table action buttons

- Add aria-label to edit buttons: 'Edit [item name]'
- Add aria-label to delete buttons: 'Delete [item name]'
- Add aria-label to save/cancel buttons in edit/add modes
- Add aria-label to 'Add New' button

Fixes unlabeled icon buttons that were inaccessible to screen readers and AI browsers.
2025-09-25 12:13:10 +02:00

289 lines
9.0 KiB
TypeScript

"use client"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { SelectProps } from "@radix-ui/react-select"
import { format } from "date-fns"
import { CalendarIcon, Upload } from "lucide-react"
import { InputHTMLAttributes, TextareaHTMLAttributes, useEffect, useRef, useState } from "react"
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
title?: string
hideIfEmpty?: boolean
isRequired?: boolean
}
export function FormInput({ title, hideIfEmpty = false, isRequired = false, ...props }: FormInputProps) {
const isEmpty = (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value
if (hideIfEmpty && isEmpty) {
return null
}
return (
<label className="flex flex-col gap-1">
{title && <span className="text-sm font-medium">{title}</span>}
<Input
{...props}
id={props.id || (props as { name?: string }).name}
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
data-1p-ignore
/>
</label>
)
}
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
title?: string
hideIfEmpty?: boolean
isRequired?: boolean
}
export function FormTextarea({ title, hideIfEmpty = false, isRequired = false, ...props }: FormTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isEmpty = (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const resize = () => {
textarea.style.height = "auto"
textarea.style.height = `${textarea.scrollHeight + 5}px`
}
resize() // initial resize
textarea.addEventListener("input", resize)
return () => textarea.removeEventListener("input", resize)
}, [props.value, props.defaultValue])
if (hideIfEmpty && isEmpty) {
return null
}
return (
<label className="flex flex-col gap-1">
{title && <span className="text-sm font-medium">{title}</span>}
<Textarea
ref={textareaRef}
{...props}
id={props.id || (props as { name?: string }).name}
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
data-1p-ignore
/>
</label>
)
}
export const FormSelect = ({
items,
title,
emptyValue,
placeholder,
hideIfEmpty = false,
isRequired = false,
onValueChange,
name,
id,
...props
}: {
items: Array<{ code: string; name: string; color?: string; badge?: string; logo?: string }>
title?: string
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean
isRequired?: boolean
name?: string
id?: string
} & SelectProps) => {
const [internalValue, setInternalValue] = useState<string | undefined>(
(props.value as string | undefined) || (props.defaultValue as string | undefined)
)
const isControlled = props.value !== undefined
const selectValue = (isControlled ? (props.value as string | undefined) : internalValue) || ""
const isEmpty = !selectValue || selectValue.toString().trim() === ""
const labelId = title ? `${id || name || "select"}-label` : undefined
const controlId = id || name
const handleChange = (v: string) => {
if (!isControlled) setInternalValue(v)
onValueChange?.(v)
}
if (hideIfEmpty && isEmpty) {
return null
}
return (
<span className="flex flex-col gap-1">
{title && (
<span className="text-sm font-medium" id={labelId}>
{title}
</span>
)}
{/* Hidden input to ensure form submissions include this value */}
{name && <input type="hidden" name={name} value={selectValue} />}
<Select
{...props}
onValueChange={handleChange}
{...(isControlled ? { value: props.value as string } : { defaultValue: props.defaultValue as string })}
>
<SelectTrigger
id={controlId}
aria-labelledby={labelId}
className={cn("w-full min-w-[150px] bg-background", isRequired && isEmpty && "bg-yellow-50")}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{emptyValue && <SelectItem value="-">{emptyValue}</SelectItem>}
{items.map((item) => (
<SelectItem key={item.code} value={item.code}>
<div className="flex items-center gap-2 text-base pr-2">
{item.logo && <Image src={item.logo} alt={item.name} width={20} height={20} className="rounded-full" />}
{item.badge && <Badge className="px-2">{item.badge}</Badge>}
{!item.badge && item.color && (
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />
)}
{item.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</span>
)
}
export const FormDate = ({
name,
title,
placeholder = "Select date",
defaultValue,
...props
}: {
name: string
title?: string
placeholder?: string
defaultValue?: Date
}) => {
const [date, setDate] = useState<Date | undefined>(defaultValue)
const [manualInput, setManualInput] = useState<string>(date ? format(date, "yyyy-MM-dd") : "")
const handleDateSelect = (newDate: Date | undefined) => {
setDate(newDate)
setManualInput(newDate ? format(newDate, "yyyy-MM-dd") : "")
}
const handleManualInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setManualInput(e.target.value)
setDate(undefined)
try {
const newDate = new Date(e.currentTarget.value)
if (!isNaN(newDate.getTime())) {
setDate(newDate)
}
} catch {}
}
return (
<label className="flex flex-col gap-1">
{title && <span className="text-sm font-medium">{title}</span>}
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal bg-background",
!date && "text-muted-foreground"
)}
>
{date ? format(date, "PPP") : placeholder}
<CalendarIcon className="ml-1 h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-1 flex flex-col gap-2" align="start">
<Input
type="text"
name={name}
value={manualInput}
onChange={handleManualInputChange}
className="text-center"
/>
<Calendar mode="single" selected={date} onSelect={handleDateSelect} initialFocus {...props} />
</PopoverContent>
</Popover>
</div>
</label>
)
}
export const FormAvatar = ({
title,
defaultValue,
className,
onChange,
...props
}: {
title?: string
defaultValue?: string
className?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
} & InputHTMLAttributes<HTMLInputElement>) => {
const [preview, setPreview] = useState<string | null>(defaultValue || null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result as string)
}
reader.readAsDataURL(file)
}
// Call the original onChange if provided
if (onChange) {
onChange(e)
}
}
return (
<label className="inline-block">
{title && <span className="text-sm font-medium">{title}</span>}
<div className={cn("relative group", className)}>
<div className="absolute inset-0 flex items-center justify-center bg-background rounded-lg overflow-hidden">
{preview ? (
<img src={preview} alt="Avatar preview" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<span className="text-muted-foreground">No image</span>
</div>
)}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
<input
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
{...props}
/>
<Upload className="z-10 bg-white/30 text-white p-1 rounded-sm h-7 w-8 cursor-pointer" />
</div>
</div>
</label>
)
}