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:
@@ -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;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user