I'm building a React component that mimics a custom dropdown using Headless UI's Listbox, with radio buttons inside the dropdown. It supports a "Select All" option that should populate the payload with all options' codes.
Each dropdown item is a radio button.
The first item is "Select All", which should select all options.
It’s used for selecting countries and later also languages.
import { Listbox } from '@headlessui/react'; import { ChevronDown } from 'lucide-react'; import { Fragment } from 'react';
interface Option {
name: string;
code: string;
}
interface RadioSingleSelectProps {
options: Option[];
selectedCodes: string[];
onChange: (codes: string[]) => void;
placeholder?: string;
}
export default function RadioSingleSelect({
options,
selectedCodes,
onChange,
placeholder = 'Select an option',
}: RadioSingleSelectProps) {
const selectOptions: Option[] = [
{ name: 'Select All', code: '__ALL__' },
...options,
];
const allCodes = options.map((o) => o.code);
const isAllSelected = selectedCodes.length === allCodes.length;
const selectedCode = isAllSelected ? '__ALL__' : selectedCodes[0];
const selectedOption =
selectOptions.find((opt) => opt.code === selectedCode) ||
{ name: placeholder, code: '' };
const handleChange = (option: Option) => {
if (option.code === '__ALL__') {
onChange(allCodes);
} else {
onChange([option.code]);
}
};
return (
<div className="w-full text-sm">
<Listbox value={selectedOption} onChange={handleChange}>
<div className="relative">
<Listbox.Button className="relative w-full cursor-pointer rounded-md bg-gray-100 py-2 pl-4 pr-10 text-left border border-gray-300 h-12 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
<span className="block truncate font-normal text-gray-800">
{selectedOption.code ? selectedOption.name : placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center">
<ChevronDown className="h-4 w-4 text-gray-400" />
</span>
</Listbox.Button>
<Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white border border-gray-200 shadow-lg z-50">
{selectOptions.map((option) => (
<Listbox.Option
key={option.code}
value={option}
as={Fragment}
>
{({ selected, active }) => (
<li
className={`relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm ${
active ? 'bg-purple-100 text-purple-900' : 'text-gray-900'
}`}
>
<span
className={`block truncate ${
selected ? 'font-medium' : 'font-normal'
}`}
>
{option.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-3 flex items-center">
<input
type="radio"
checked={selected}
readOnly
className="h-4 w-4 text-purple-600"
/>
</span>
)}
</li>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
</div>
);
}
Your component is almost correct, but there are two main issues in how you're handling the radio buttons.
Changes made:
Always render the <input type="radio">
for every option.
Make its checked
prop based on comparing the option’s code
to selectedCode
(or __ALL__
for “Select All”).
Use pointer-events-none
on the <input>
so clicks don’t conflict with Listbox’s own click handling.
Replace your current <li>
inside Listbox.Option
with this:
<li
className={`relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm ${
active ? 'bg-purple-100 text-purple-900' : 'text-gray-900'
}`}
>
<span className="absolute inset-y-0 left-3 flex items-center">
<input
type="radio"
checked={
option.code === '__ALL__'
? isAllSelected
: selectedCode === option.code
}
readOnly
className="h-4 w-4 text-purple-600 pointer-events-none"
/>
</span>
<span
className={`block truncate ${
option.code === '__ALL__'
? isAllSelected
: selectedCode === option.code
? 'font-medium'
: 'font-normal'
}`}
>
{option.name}
</span>
</li>