Files
Anthony Lio 5d78b1da1a fix(docs): a11y projectconfigvariables (#50002)
## What kind of change does this PR introduce?

bug fix for accessibility, fixes
[docs-1280](https://linear.app/supabase/issue/DOCS-1280/projectconfigvariables-label-the-readonly-inputs-and-name)

## What is the current behavior?

the project url and api key fields in `ProjectConfigVariables` have no
associated label, so a screen reader announces an edit field with no
indication of which value it holds

## What is the new behavior?

- associates a `<label>` with each readonly input, so the fields
announce as "project url" and "publishable key"
- names each copy button after the value it copies
- drops `role="combobox"` from the trigger, keeping the `aria-haspopup`,
`aria-expanded` and `aria-controls` radix already supplies
- names the trigger from its content instead of `aria-label`, so it
announces the current selection
- names the shared `CommandInput` reset button and hides its icons

## test

- `pnpm dev:docs`
- `/docs/guides/getting-started/quickstarts/nextjs` (`url` +
`publishable`)
- `/docs/guides/auth/server-side/creating-a-client`, branch selector,
needs a branching-enabled project
- `/docs/guides/observability/log-drains`
- `api_settings` in any getting-started quickstart

## Additional context

reverses part of #49952 as that pr added `aria-label` to satisfy
`button-name`, but did replace the accessible name rather than adding to
it _ the sr-only prefix added here keeps the rule passing and announces
the selection

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Accessibility**
* Improved screen reader support for variable configuration controls,
including clearer labels and copy-status announcements.
* Enhanced combobox and search interactions with accessible labeling,
empty-result announcements, and clearer reset-button names.
* Decorative icons and visual-only messages are now hidden from
assistive technologies.
* **Tests**
* Added accessibility coverage for search input icons and the
clear-search control.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-10 13:22:00 +03:00

168 lines
5.1 KiB
TypeScript

import { useIntersectionObserver } from '~/hooks/useIntersectionObserver'
import { noop } from 'lodash-es'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import {
Button_Shadcn_ as Button,
cn,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
export interface ComboBoxOption {
id: string
value: string
displayName: string
disabled?: boolean
}
export function ComboBox<Opt extends ComboBoxOption>({
isLoading,
disabled,
name,
options,
selectedOption,
selectedDisplayName,
onSelectOption = noop,
className,
search = '',
hasNextPage = false,
isFetching = false,
isFetchingNextPage = false,
fetchNextPage,
setSearch = () => {},
useCommandSearch = true,
}: {
isLoading: boolean
disabled?: boolean
name: string
options: Opt[]
selectedOption?: string
selectedDisplayName?: string
onSelectOption?: (newValue: string) => void
className?: string
search?: string
hasNextPage?: boolean
isFetching?: boolean
isFetchingNextPage?: boolean
fetchNextPage?: () => void
setSearch?: (value: string) => void
useCommandSearch?: boolean
}) {
const [open, setOpen] = useState(false)
const scrollRootRef = useRef<HTMLDivElement | null>(null)
const [sentinelRef, entry] = useIntersectionObserver({
root: scrollRootRef.current,
threshold: 0,
rootMargin: '0px',
})
useEffect(() => {
if (!isLoading && !isFetching && !isFetchingNextPage && hasNextPage && entry?.isIntersecting) {
fetchNextPage?.()
}
}, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage])
return (
<Popover
open={open}
onOpenChange={(value) => {
setOpen(value)
if (!value) setSearch('')
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
aria-expanded={open}
className={cn(
'overflow-hidden',
'h-auto min-h-10',
'flex justify-between',
'border-none',
'py-0 pl-0 pr-1 text-left',
className
)}
>
<span className="sr-only">{name}: </span>
{selectedDisplayName ??
(isLoading && options.length > 0
? 'Loading...'
: options.length === 0
? `No ${name} found`
: `Select a ${name}...`)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start">
<Command shouldFilter={useCommandSearch} label={`Search ${name}`}>
<CommandInput
placeholder={`Search ${name}...`}
className="border-none ring-0"
showResetIcon
value={search}
onValueChange={setSearch}
handleReset={() => setSearch('')}
/>
<span className="sr-only" role="status">
{!isLoading && search.length > 0 && options.length === 0 ? `No ${name} found` : ''}
</span>
<CommandList label={`${name} options`}>
<CommandGroup>
{isLoading ? (
<div className="px-2 py-1 flex flex-col gap-2">
<ShimmeringLoader className="w-full" />
<ShimmeringLoader className="w-4/5" />
</div>
) : (
<>
{search.length > 0 && options.length === 0 && (
<p className="text-xs text-center text-foreground-lighter py-3" aria-hidden>
No {name}s found based on your search
</p>
)}
<ScrollArea className={options.length > 7 ? 'h-[210px]' : ''}>
{options.map((option) => (
<CommandItem
key={option.id}
disabled={option.disabled}
value={option.value}
onSelect={(selectedValue: string) => {
setOpen(false)
onSelectOption(selectedValue)
}}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedOption === option.value ? 'opacity-100' : 'opacity-0'
)}
aria-hidden
/>
{option.displayName}
</CommandItem>
))}
<div ref={sentinelRef} className="h-1 -mt-1" />
{hasNextPage && <ShimmeringLoader className="px-2 py-3" />}
</ScrollArea>
</>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}