Fix to ensure labels, descriptions and validation errors are correctly linked to their inputs (#50080)

## Problem

`FormItemLayout` does not correctly binds inputs descriptions and
validation messages to their inputs. This is because the input ids are
generated and not correctly propagated to the `FormMessage` and
`FormDescription` components. Besides, we still pass `name` or `id`
directly to the inputs or `FormItemLayout` in some places.

## Solution

- Fix `FormItemLayout` to correctly binds inputs descriptions and
validation messages to their inputs
- Fix incorrect usages
- Fix Design System documentation

## How to test

The issue is visible in production:
- Open https://supabase.com/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. You'll see they often
don't match

Do the same on staging:
- Open
https://design-system-git-fix-a11y-form-input-descriptions-supabase.vercel.app/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. They now match

Dashboard fixes:
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/tokens:
_Expires in_ select button is now correctly linked to its label
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/me:
the switches are now correctly linked to their label
- In Database/Indexes: the select buttons when creating an index are now
correctly linked to their label
- All other changes are the same things
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-09-08 09:47:32 +02:00
1 parent 66a748cdd1
commit bfb0737d14
11 files changed
+291 -306

No files matched your search

@@ -8,6 +8,7 @@ import {
Button,
Calendar,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
@@ -53,11 +54,13 @@ export default function DatePickerForm() {
<FormItem className="flex flex-col">
<FormLabel>Date of birth</FormLabel>
<DatePicker>
<DatePickerTrigger asChild>
<DatePickerButton isInvalid={fieldState.invalid}>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</DatePickerButton>
</DatePickerTrigger>
<FormControl>
<DatePickerTrigger asChild>
<DatePickerButton isInvalid={fieldState.invalid}>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</DatePickerButton>
</DatePickerTrigger>
</FormControl>
<DatePickerContent>
<Calendar
mode="single"
@@ -613,14 +613,16 @@ export default function FormPatternsPageLayout() {
values={field.value}
size="small"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
<FormControl>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
</FormControl>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
@@ -675,23 +677,23 @@ export default function FormPatternsPageLayout() {
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl>
<DatePicker>
<DatePicker>
<FormControl>
<DatePickerTrigger asChild>
<DatePickerButton block isInvalid={fieldState.invalid}>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</DatePickerButton>
</DatePickerTrigger>
<DatePickerContent>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</DatePickerContent>
</DatePicker>
</FormControl>
</FormControl>
<DatePickerContent>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</DatePickerContent>
</DatePicker>
</FormItemLayout>
)}
/>
@@ -610,14 +610,16 @@ export default function FormPatternsSidePanel() {
size="small"
className="w-full"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
<FormControl>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
</FormControl>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
@@ -677,23 +679,23 @@ export default function FormPatternsSidePanel() {
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl className="col-span-6">
<DatePicker>
<DatePicker>
<FormControl className="col-span-6">
<DatePickerTrigger asChild>
<DatePickerButton block isInvalid={fieldState.invalid}>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</DatePickerButton>
</DatePickerTrigger>
<DatePickerContent>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</DatePickerContent>
</DatePicker>
</FormControl>
</FormControl>
<DatePickerContent>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</DatePickerContent>
</DatePicker>
</FormItemLayout>
)}
/>
@@ -72,25 +72,25 @@ export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => {
name="expiresAt"
control={control}
render={({ field }) => (
<FormItemLayout id="expiresAt" label="Expires in" layout="flex-row-reverse">
<FormItemLayout label="Expires in" layout="flex-row-reverse">
<div className="flex gap-2 w-full">
<FormControl className="grow">
<Select value={field.value} onValueChange={handleExpiryChange}>
<SelectTrigger id="expiresAt">
<Select value={field.value} onValueChange={handleExpiryChange}>
<FormControl className="grow">
<SelectTrigger>
<SelectValue placeholder="Select an expiry" />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-2">
{option.label}
{option.recommended && <Badge variant="success">Recommended</Badge>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormControl>
<SelectContent>
{EXPIRY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-2">
{option.label}
{option.recommended && <Badge variant="success">Recommended</Badge>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{field.value === 'custom' && (
<FormField
@@ -26,15 +26,9 @@ export function DashboardToggle<T extends FieldValues>({
control={form.control}
name={name}
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={label}
description={description}
id={name}
>
<FormItemLayout layout="flex-row-reverse" label={label} description={description}>
<FormControl>
<Switch
id={name}
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
@@ -118,8 +118,8 @@ const FormField = ({
) : null
}
>
<FormControl>
<Popover>
<Popover>
<FormControl>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -130,18 +130,18 @@ const FormField = ({
{field.value ? format(new Date(field.value), 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date?.toISOString())
}}
initialFocus
/>
</PopoverContent>
</Popover>
</FormControl>
</FormControl>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date?.toISOString())
}}
initialFocus
/>
</PopoverContent>
</Popover>
</FormItemLayout>
)}
/>
@@ -170,16 +170,9 @@ const FormField = ({
>
<FormControl className="col-span-6">
{properties.isSecret ? (
<DataInput
{...field}
id={name}
size="small"
copy
reveal
readOnly={readOnly}
/>
<DataInput {...field} size="small" copy reveal readOnly={readOnly} />
) : (
<Input {...field} id={name} readOnly={readOnly} />
<Input {...field} readOnly={readOnly} />
)}
</FormControl>
</FormItemLayout>
@@ -211,7 +204,6 @@ const FormField = ({
<FormControl className="col-span-6">
<Textarea
{...field}
id={name}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
@@ -244,12 +236,11 @@ const FormField = ({
) : null
}
>
<FormControl className="col-span-6">
{properties.units ? (
{properties.units ? (
<FormControl className="col-span-6">
<InputGroup>
<FormInputGroupInput
{...field}
id={name}
type="number"
onChange={(e) =>
field.onChange(e.target.value === '' ? '' : Number(e.target.value))
@@ -262,18 +253,19 @@ const FormField = ({
</ReactMarkdown>
</InputGroupAddon>
</InputGroup>
) : (
</FormControl>
) : (
<FormControl className="col-span-6">
<Input
{...field}
id={name}
type="number"
onChange={(e) =>
field.onChange(e.target.value === '' ? '' : Number(e.target.value))
}
readOnly={readOnly}
/>
)}
</FormControl>
</FormControl>
)}
</FormItemLayout>
)}
/>
@@ -310,12 +302,7 @@ const FormField = ({
}
>
<FormControl className="col-span-6">
<Switch
id={name}
checked={field.value}
onCheckedChange={field.onChange}
size="small"
/>
<Switch checked={field.value} onCheckedChange={field.onChange} size="small" />
</FormControl>
</FormItemLayout>
)}
@@ -347,33 +334,33 @@ const FormField = ({
) : null
}
>
<FormControl className="col-span-6">
<Select
defaultValue={properties.enum[0]?.value}
value={field.value}
onValueChange={field.onChange}
>
<Select
defaultValue={properties.enum[0]?.value}
value={field.value}
onValueChange={field.onChange}
>
<FormControl className="col-span-6">
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{properties.enum.map((option: Enum) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex gap-2 items-center">
{option.icon ? (
<img
alt={`${option.label} icon`}
className="h-6 w-6"
src={`${BASE_PATH}/img/icons/${option.icon}`}
/>
) : null}
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormControl>
<SelectContent>
{properties.enum.map((option: Enum) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex gap-2 items-center">
{option.icon ? (
<img
alt={`${option.label} icon`}
className="h-6 w-6"
src={`${BASE_PATH}/img/icons/${option.icon}`}
/>
) : null}
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>
@@ -201,16 +201,15 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
control={form.control}
name="schema"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Select a schema" id="schema">
<FormControl className="col-span-6">
<Popover
modal={false}
open={schemaDropdownOpen}
onOpenChange={setSchemaDropdownOpen}
>
<FormItemLayout layout="horizontal" label="Select a schema">
<Popover
modal={false}
open={schemaDropdownOpen}
onOpenChange={setSchemaDropdownOpen}
>
<FormControl className="col-span-6">
<PopoverTrigger asChild>
<Button
id="schema"
variant="default"
size={'medium'}
className={`w-full [&>span]:w-full text-left`}
@@ -225,56 +224,56 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
{field.value || 'Choose a schema'}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command>
<CommandInput
placeholder="Find schema..."
value={schemaSearchTerm}
onValueChange={setSchemaSearchTerm}
/>
<CommandList
className={cn(
(schemas ?? []).length > 7 && 'max-h-[210px]! overflow-y-auto'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>No schemas found</CommandEmpty>
<CommandGroup>
{(schemas ?? []).map((schema) => (
<CommandItem
key={schema.name}
value={schema.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(schema.name)
form.setValue('table', '')
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
setSearchTerm('')
}}
>
<Check
className={cn(
'text-brand',
schema.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{schema.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormControl>
</FormControl>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command>
<CommandInput
placeholder="Find schema..."
value={schemaSearchTerm}
onValueChange={setSchemaSearchTerm}
/>
<CommandList
className={cn(
(schemas ?? []).length > 7 && 'max-h-[210px]! overflow-y-auto'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>No schemas found</CommandEmpty>
<CommandGroup>
{(schemas ?? []).map((schema) => (
<CommandItem
key={schema.name}
value={schema.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(schema.name)
form.setValue('table', '')
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
setSearchTerm('')
}}
>
<Check
className={cn(
'text-brand',
schema.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{schema.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItemLayout>
)}
/>
@@ -290,25 +289,23 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
<FormItemLayout
layout="horizontal"
label="Select a table"
id="table"
description={
isSelectEntityDisabled &&
!isLoadingEntities &&
'Create a table in this schema via the Table or SQL editor first'
}
>
<FormControl className="col-span-6">
<Popover
modal={false}
open={tableDropdownOpen}
onOpenChange={setTableDropdownOpen}
>
<Popover
modal={false}
open={tableDropdownOpen}
onOpenChange={setTableDropdownOpen}
>
<FormControl className="col-span-6">
<PopoverTrigger
asChild
disabled={isSelectEntityDisabled || isLoadingEntities}
>
<Button
id="table"
variant="default"
size="medium"
className={cn(
@@ -330,65 +327,65 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
: 'Choose a table'}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
{/* [Terry] shouldFilter context:
</FormControl>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
{/* [Terry] shouldFilter context:
https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */}
<Command shouldFilter={false}>
<CommandInput
placeholder="Find table..."
value={searchTerm}
onValueChange={handleSearchChange}
/>
<CommandList
className={cn(
entityTypes.length > 7 && 'max-h-[210px]! overflow-y-auto'
<Command shouldFilter={false}>
<CommandInput
placeholder="Find table..."
value={searchTerm}
onValueChange={handleSearchChange}
/>
<CommandList
className={cn(
entityTypes.length > 7 && 'max-h-[210px]! overflow-y-auto'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>
{isLoadingEntities ? (
<div className="flex items-center gap-2 text-center justify-center">
<Loader2 size={12} className="animate-spin" />
Loading...
</div>
) : (
'No tables found'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>
{isLoadingEntities ? (
<div className="flex items-center gap-2 text-center justify-center">
<Loader2 size={12} className="animate-spin" />
Loading...
</div>
) : (
'No tables found'
)}
</CommandEmpty>
<CommandGroup>
{entityTypes.map((entity) => (
<CommandItem
key={entity.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(entity.name)
setTableDropdownOpen(false)
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
}}
>
<Check
className={cn(
'text-brand',
entity.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{entity.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormControl>
</CommandEmpty>
<CommandGroup>
{entityTypes.map((entity) => (
<CommandItem
key={entity.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(entity.name)
setTableDropdownOpen(false)
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
}}
>
<Check
className={cn(
'text-brand',
entity.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{entity.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItemLayout>
)}
/>
@@ -124,7 +124,7 @@ export const ColumnType = ({
className={className}
>
<FormControl>
<Input {...field} id={name} disabled readOnly />
<Input {...field} disabled readOnly />
</FormControl>
</FormItemLayout>
)
@@ -135,29 +135,31 @@ export const ColumnType = ({
Type
</FormLabel>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
role="combobox"
size={'small'}
aria-expanded={open}
aria-controls={listboxId}
className={cn(
'w-full justify-between bg-background-control',
!field.value && 'text-foreground-lighter'
)}
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
title={field.value && field.value.replaceAll('"', '')}
>
{field.value ? (
<div className="flex gap-2 items-center">
<span>{inferIcon(getOptionByName(field.value)?.type ?? '')}</span>
<span className="block truncate">{field.value.replaceAll('"', '')}</span>
</div>
) : (
'Choose a column type...'
)}
</Button>
</PopoverTrigger>
<FormControl>
<PopoverTrigger asChild>
<Button
role="combobox"
size={'small'}
aria-expanded={open}
aria-controls={listboxId}
className={cn(
'w-full justify-between bg-background-control',
!field.value && 'text-foreground-lighter'
)}
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
title={field.value && field.value.replaceAll('"', '')}
>
{field.value ? (
<div className="flex gap-2 items-center">
<span>{inferIcon(getOptionByName(field.value)?.type ?? '')}</span>
<span className="block truncate">{field.value.replaceAll('"', '')}</span>
</div>
) : (
'Choose a column type...'
)}
</Button>
</PopoverTrigger>
</FormControl>
<PopoverContent id={listboxId} className="w-[460px] p-0" side="bottom" align="center">
<Command>
<CommandInput
@@ -58,36 +58,35 @@ export const OrganizationSelector = ({
control={form.control}
name="organization"
render={({ field }) => (
<FormItemLayout id="organization" label="Organization" layout="horizontal">
<FormItemLayout label="Organization" layout="horizontal">
{(organizations?.length ?? 0) > 0 && (
<FormControl>
<Select
name="organization"
onValueChange={(slug) => {
field.onChange(slug)
router.push(`/new/${slug}`)
}}
value={field.value}
defaultValue={field.value}
disabled={disableOrganizationSelection}
>
<SelectTrigger id="organization">
<Select
onValueChange={(slug) => {
field.onChange(slug)
router.push(`/new/${slug}`)
}}
value={field.value}
defaultValue={field.value}
disabled={disableOrganizationSelection}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{organizations?.map((x) => (
<SelectItem key={x.id} value={x.slug}>
<div className="flex justify-between items-center gap-2 w-full">
<span>{x.name}</span>
<Badge className="mt-px">{x.plan.name}</Badge>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormControl>
<SelectContent>
<SelectGroup>
{organizations?.map((x) => (
<SelectItem key={x.id} value={x.slug}>
<div className="flex justify-between items-center gap-2 w-full">
<span>{x.name}</span>
<Badge className="mt-px">{x.plan.name}</Badge>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FormItemLayout>
)}
@@ -16,9 +16,9 @@ export const ProjectNameInput = ({ form }: ProjectNameInputProps) => {
control={form.control}
name="projectName"
render={({ field }) => (
<FormItemLayout id="projectName" label="Project name" layout="horizontal">
<FormItemLayout label="Project name" layout="horizontal">
<FormControl>
<Input {...field} id="projectName" placeholder="Project name" />
<Input {...field} placeholder="Project name" />
</FormControl>
</FormItemLayout>
)}
@@ -333,7 +333,6 @@ export const FormLayout = React.forwardRef<
<FormDescription
className={cn(DescriptionVariants({ size, layout }))}
data-formlayout-id={'description'}
id={`${id}-description`}
>
{description}
</FormDescription>