mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
5d78b1da1a
## 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 -->
168 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|