Files
pn-new-crm/src/components/shared/inline-timezone-field.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

103 lines
3.1 KiB
TypeScript

'use client';
import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { formatTimezoneLabel } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface InlineTimezoneFieldProps {
value: string | null | undefined;
onSave: (next: string | null) => Promise<void>;
/** Optional country to surface "Suggested" zones in the picker. */
countryHint?: CountryCode | null;
emptyText?: string;
disabled?: boolean;
className?: string;
'data-testid'?: string;
}
export function InlineTimezoneField({
value,
onSave,
countryHint,
emptyText = '—',
disabled,
className,
'data-testid': testId,
}: InlineTimezoneFieldProps) {
const [editing, setEditing] = 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) {
pickedRef.current = true;
if (next === (value ?? null)) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(next);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
}
if (editing) {
return (
<div className={cn('flex items-center gap-1', className)}>
<TimezoneCombobox
value={value}
onChange={(tz) => void commit(tz)}
countryHint={countryHint ?? undefined}
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" />}
</div>
);
}
const display = value ? formatTimezoneLabel(value) : null;
return (
<button
type="button"
disabled={disabled}
onClick={() => setEditing(true)}
data-testid={testId}
className={cn(
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
>
<span className={cn('flex-1', !display && 'text-muted-foreground')}>
{display ?? emptyText}
</span>
{!disabled && (
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" />
)}
</button>
);
}