mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
* 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.
289 lines
9.0 KiB
TypeScript
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>
|
|
)
|
|
}
|