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>
This commit is contained in:
Matt Ciaccio
2026-05-03 16:14:51 +02:00
parent e9359fc431
commit 596476280d
6 changed files with 109 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react'; import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -225,6 +225,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
function flagEmoji(code: string | null | undefined): string {
if (!code || code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
}
function CountryFieldInline({ function CountryFieldInline({
value, value,
onSave, onSave,
@@ -233,20 +241,34 @@ function CountryFieldInline({
onSave: (iso: string | null) => Promise<void>; onSave: (iso: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
// Tracks whether a value was picked this edit cycle so the open-change
// handler doesn't double-exit while commit is still in flight.
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<CountryCombobox <CountryCombobox
value={value ?? null} value={value ?? null}
onChange={async (iso) => { onChange={async (iso) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(iso ?? null); await onSave(iso ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
// Drop the user straight into the picker — no extra click on the
// trigger required.
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the popover closes without a pick so
// the user isn't stuck staring at a "Select country…" trigger.
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }
const display = value ? getCountryName(value, 'en') : null; const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
return ( return (
<button <button
type="button" type="button"
@@ -268,17 +290,25 @@ function SubdivisionFieldInline({
onSave: (code: string | null) => Promise<void>; onSave: (code: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<SubdivisionCombobox <SubdivisionCombobox
value={value ?? null} value={value ?? null}
country={country} country={country}
onChange={async (code) => { onChange={async (code) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(code ?? null); await onSave(code ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
defaultOpen
onOpenChange={(open) => {
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }

View File

@@ -30,6 +30,12 @@ interface CountryComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers so the
* user lands directly in the picker after clicking the edit affordance. */
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;
} }
/** /**
@@ -58,8 +64,14 @@ export function CountryCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: CountryComboboxProps) { }: CountryComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'); const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
// Pre-build the options list once per locale change so the cmdk filter // Pre-build the options list once per locale change so the cmdk filter
@@ -75,7 +87,7 @@ export function CountryCombobox({
const selected = value ? options.find((o) => o.code === value) : undefined; const selected = value ? options.find((o) => o.code === value) : undefined;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,8 +31,12 @@ export function InlineCountryField({
}: InlineCountryFieldProps) { }: InlineCountryFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: CountryCode | null) { async function commit(next: CountryCode | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -51,7 +55,23 @@ export function InlineCountryField({
if (editing) { if (editing) {
return ( return (
<div className={cn('flex items-center gap-1', className)}> <div className={cn('flex items-center gap-1', className)}>
<CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} /> <CountryCombobox
value={value}
onChange={(iso) => void commit(iso)}
data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// When the dropdown closes without a selection, leave edit mode
// so the user isn't stuck staring at the trigger button. If a
// pick happened, commit() handles the exit (and may need to keep
// edit mode briefly to show the saving spinner).
if (!open && !pickedRef.current) {
setEditing(false);
}
// Reset for the next open cycle.
if (open) pickedRef.current = false;
}}
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,8 +31,12 @@ export function InlineTimezoneField({
}: InlineTimezoneFieldProps) { }: InlineTimezoneFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: string | null) { async function commit(next: string | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -56,6 +60,16 @@ export function InlineTimezoneField({
onChange={(tz) => void commit(tz)} onChange={(tz) => void commit(tz)}
countryHint={countryHint ?? undefined} countryHint={countryHint ?? undefined}
data-testid={testId} data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the dropdown closes without a pick,
// so the user isn't stuck looking at the trigger. commit() owns
// the exit when a value was selected.
if (!open && !pickedRef.current) {
setEditing(false);
}
if (open) pickedRef.current = false;
}}
/> />
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>

View File

@@ -32,6 +32,11 @@ interface SubdivisionComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: 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({ export function SubdivisionCombobox({
@@ -44,8 +49,14 @@ export function SubdivisionCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: SubdivisionComboboxProps) { }: SubdivisionComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const options = useMemo(() => { const options = useMemo(() => {
if (!country) return []; if (!country) return [];
@@ -64,7 +75,7 @@ export function SubdivisionCombobox({
else triggerLabel = placeholder; else triggerLabel = placeholder;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -29,6 +29,11 @@ interface TimezoneComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: 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 TimezoneCombobox({ export function TimezoneCombobox({
@@ -41,8 +46,14 @@ export function TimezoneCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: TimezoneComboboxProps) { }: TimezoneComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const allOptions = useMemo(() => { const allOptions = useMemo(() => {
return listAllTimezones().map((tz) => ({ return listAllTimezones().map((tz) => ({
@@ -66,7 +77,7 @@ export function TimezoneCombobox({
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder; const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}