Files
pn-new-crm/src/components/shared/subdivision-combobox.tsx
Matt Ciaccio 596476280d feat(ui): inline-edit dropdowns auto-open + auto-exit on dismiss
When a user clicks an inline-edit affordance for country / timezone /
subdivision, the field flipped to its combobox trigger but the popover
didn't open — they had to click again. And if they dismissed the popover
without picking, the field stayed in edit mode showing a "Select country…"
trigger they couldn't get out of.

Combobox primitives (country / timezone / subdivision) now accept:
  - defaultOpen — open on first render
  - onOpenChange — fired on every open/close transition

InlineCountryField / InlineTimezoneField / and the country + subdivision
fields inside addresses-editor pass defaultOpen=true and use onOpenChange
to auto-exit edit mode when the popover closes without a selection. A
pickedRef gate prevents the close-handler from racing the commit() exit
when the user does pick a value.

Bonus: addresses-editor now renders a flag emoji next to the country name
in the read-only state (regional-indicator pair from the ISO code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:51 +02:00

136 lines
4.3 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { subdivisionsForCountry } from '@/lib/i18n/subdivisions';
import type { CountryCode } from '@/lib/i18n/countries';
interface SubdivisionComboboxProps {
value: string | null | undefined;
onChange: (code: string | null) => void;
/**
* Country whose subdivisions populate the dropdown. When the country
* has no recognized subdivisions, the trigger renders disabled with
* an empty-state hint so the form still lays out.
*/
country: CountryCode | null | undefined;
placeholder?: string;
disabled?: boolean;
className?: string;
clearable?: boolean;
id?: string;
'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
}
export function SubdivisionCombobox({
value,
onChange,
country,
placeholder = 'Select region…',
disabled,
className,
clearable = true,
id,
'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: SubdivisionComboboxProps) {
const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const options = useMemo(() => {
if (!country) return [];
return subdivisionsForCountry(country);
}, [country]);
const selected = value ? options.find((o) => o.code === value) : undefined;
const noCountry = !country;
const noSubdivisions = !noCountry && options.length === 0;
const isDisabled = disabled || noCountry || noSubdivisions;
let triggerLabel: string;
if (selected) triggerLabel = selected.name;
else if (noCountry) triggerLabel = 'Pick a country first';
else if (noSubdivisions) triggerLabel = 'No regions available';
else triggerLabel = placeholder;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isDisabled}
className={cn('w-full justify-between', !selected && 'text-muted-foreground', className)}
data-testid={testId}
>
<span className="truncate text-sm">{triggerLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${country ?? 'regions'}`} />
<CommandList>
<CommandEmpty>No region found.</CommandEmpty>
{clearable && value ? (
<CommandGroup>
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
</CommandGroup>
) : null}
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.code}
value={`${opt.name} ${opt.code}`}
onSelect={() => {
onChange(opt.code);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === opt.code ? 'opacity-100' : 'opacity-0')}
/>
<span className="flex-1 truncate text-sm">{opt.name}</span>
<span className="text-xs text-muted-foreground">{opt.code}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}