mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
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.
This commit is contained in:
@@ -32,6 +32,7 @@ export function FormInput({ title, hideIfEmpty = false, isRequired = false, ...p
|
|||||||
{title && <span className="text-sm font-medium">{title}</span>}
|
{title && <span className="text-sm font-medium">{title}</span>}
|
||||||
<Input
|
<Input
|
||||||
{...props}
|
{...props}
|
||||||
|
id={props.id || (props as { name?: string }).name}
|
||||||
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
|
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
/>
|
/>
|
||||||
@@ -74,6 +75,7 @@ export function FormTextarea({ title, hideIfEmpty = false, isRequired = false, .
|
|||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
{...props}
|
{...props}
|
||||||
|
id={props.id || (props as { name?: string }).name}
|
||||||
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
|
className={cn("bg-background", isRequired && isEmpty && "bg-yellow-50", props.className)}
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
/>
|
/>
|
||||||
@@ -89,6 +91,8 @@ export const FormSelect = ({
|
|||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
isRequired = false,
|
isRequired = false,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
items: Array<{ code: string; name: string; color?: string; badge?: string; logo?: string }>
|
items: Array<{ code: string; name: string; color?: string; badge?: string; logo?: string }>
|
||||||
@@ -97,8 +101,23 @@ export const FormSelect = ({
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
isRequired?: boolean
|
isRequired?: boolean
|
||||||
|
name?: string
|
||||||
|
id?: string
|
||||||
} & SelectProps) => {
|
} & SelectProps) => {
|
||||||
const isEmpty = (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value
|
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) {
|
if (hideIfEmpty && isEmpty) {
|
||||||
return null
|
return null
|
||||||
@@ -106,13 +125,23 @@ export const FormSelect = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-col gap-1">
|
<span className="flex flex-col gap-1">
|
||||||
{title && <span className="text-sm font-medium">{title}</span>}
|
{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
|
<Select
|
||||||
value={props.value}
|
|
||||||
onValueChange={onValueChange}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
{...(isControlled ? { value: props.value as string } : { defaultValue: props.defaultValue as string })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn("w-full min-w-[150px] bg-background", isRequired && isEmpty && "bg-yellow-50")}>
|
<SelectTrigger
|
||||||
|
id={controlId}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
className={cn("w-full min-w-[150px] bg-background", isRequired && isEmpty && "bg-yellow-50")}
|
||||||
|
>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -120,9 +149,7 @@ export const FormSelect = ({
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SelectItem key={item.code} value={item.code}>
|
<SelectItem key={item.code} value={item.code}>
|
||||||
<div className="flex items-center gap-2 text-base pr-2">
|
<div className="flex items-center gap-2 text-base pr-2">
|
||||||
{item.logo && (
|
{item.logo && <Image src={item.logo} alt={item.name} width={20} height={20} className="rounded-full" />}
|
||||||
<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 && <Badge className="px-2">{item.badge}</Badge>}
|
||||||
{!item.badge && item.color && (
|
{!item.badge && item.color && (
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editingItem[column.key]}
|
checked={editingItem[column.key]}
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...editingItem,
|
...editingItem,
|
||||||
@@ -65,6 +66,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<select
|
<select
|
||||||
value={editingItem[column.key]}
|
value={editingItem[column.key]}
|
||||||
className="p-2 rounded-md border bg-transparent"
|
className="p-2 rounded-md border bg-transparent"
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...editingItem,
|
...editingItem,
|
||||||
@@ -102,6 +104,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={(editingItem[column.key] as string) || ""}
|
value={(editingItem[column.key] as string) || ""}
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...editingItem,
|
...editingItem,
|
||||||
@@ -118,6 +121,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingItem[column.key] || ""}
|
value={editingItem[column.key] || ""}
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...editingItem,
|
...editingItem,
|
||||||
@@ -134,6 +138,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(newItem[column.key] || column.defaultValue)}
|
checked={Boolean(newItem[column.key] || column.defaultValue)}
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewItem({
|
setNewItem({
|
||||||
...newItem,
|
...newItem,
|
||||||
@@ -147,6 +152,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<select
|
<select
|
||||||
value={String(newItem[column.key] || column.defaultValue || "")}
|
value={String(newItem[column.key] || column.defaultValue || "")}
|
||||||
className="p-2 rounded-md border bg-transparent"
|
className="p-2 rounded-md border bg-transparent"
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewItem({
|
setNewItem({
|
||||||
...newItem,
|
...newItem,
|
||||||
@@ -181,24 +187,26 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={String(newItem[column.key] || column.defaultValue || "")}
|
value={String(newItem[column.key] || column.defaultValue || "")}
|
||||||
onChange={(e) =>
|
aria-label={String(column.label)}
|
||||||
setNewItem({
|
onChange={(e) =>
|
||||||
...newItem,
|
setNewItem({
|
||||||
[column.key]: e.target.value,
|
...newItem,
|
||||||
})
|
[column.key]: e.target.value,
|
||||||
}
|
})
|
||||||
placeholder="#FFFFFF"
|
}
|
||||||
/>
|
placeholder="#FFFFFF"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type={column.type || "text"}
|
type={column.type || "text"}
|
||||||
value={String(newItem[column.key] || column.defaultValue || "")}
|
value={String(newItem[column.key] || column.defaultValue || "")}
|
||||||
|
aria-label={String(column.label)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewItem({
|
setNewItem({
|
||||||
...newItem,
|
...newItem,
|
||||||
@@ -279,10 +287,10 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{editingId === (item.code || item.id) ? (
|
{editingId === (item.code || item.id) ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" onClick={() => handleEdit(item.code || item.id)}>
|
<Button size="sm" onClick={() => handleEdit(item.code || item.id)} aria-label="Save changes">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
|
<Button size="sm" variant="outline" onClick={() => setEditingId(null)} aria-label="Cancel editing">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -296,12 +304,18 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
startEditing(item)
|
startEditing(item)
|
||||||
setIsAdding(false)
|
setIsAdding(false)
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Edit ${String(item.name || item.code || 'item')}`}
|
||||||
>
|
>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.isDeletable && (
|
{item.isDeletable && (
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item.code || item.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(item.code || item.id)}
|
||||||
|
aria-label={`Delete ${String(item.name || item.code || 'item')}`}
|
||||||
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -320,10 +334,10 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
))}
|
))}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={handleAdd}>
|
<Button size="sm" onClick={handleAdd} aria-label="Save new item">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setIsAdding(false)}>
|
<Button size="sm" variant="outline" onClick={() => setIsAdding(false)} aria-label="Cancel adding new item">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,6 +352,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
setIsAdding(true)
|
setIsAdding(true)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}}
|
}}
|
||||||
|
aria-label="Add new item"
|
||||||
>
|
>
|
||||||
Add New
|
Add New
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user