From 72ea0a561150292830c88196d7e0ecd90eb2fc47 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 19 Jan 2026 22:58:49 +0900 Subject: [PATCH 1/6] feat: add PhoneCountryPicker component and integrate default country selection for phone fields --- .../private/editor/[form_id]/fields/route.ts | 11 +- .../extension/phone-country-picker.tsx | 146 ++++++++++++++++++ editor/components/formfield/form-field.tsx | 8 +- .../formfield/phone-field/phone-field.tsx | 3 +- editor/grida-forms-hosted/types.ts | 12 +- editor/scaffolds/panels/field-edit-panel.tsx | 81 +++++++++- 6 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 editor/components/extension/phone-country-picker.tsx diff --git a/editor/app/(api)/private/editor/[form_id]/fields/route.ts b/editor/app/(api)/private/editor/[form_id]/fields/route.ts index e6ec201be4..c26b656065 100644 --- a/editor/app/(api)/private/editor/[form_id]/fields/route.ts +++ b/editor/app/(api)/private/editor/[form_id]/fields/route.ts @@ -65,7 +65,7 @@ export async function POST( autocomplete: init.autocomplete, data: safe_data_field({ type: init.type, - data: init.data as any, + data: init.data, }) as any, accept: init.accept, multiple: init.multiple, @@ -323,9 +323,16 @@ function safe_data_field({ data, }: { type: FormInputType; - data?: FormFieldDataSchema; + data?: FormFieldDataSchema | null; }): FormFieldDataSchema | undefined | null { switch (type) { + case "tel": { + // Keep `data` as an object (if provided) so we can safely persist tel configs + // such as `default_country` without losing shape. + if (!data) return data; + if (typeof data === "object") return data; + return {}; + } case "payment": { // TODO: enhance the schema validation with external libraries if (!data || !(data as PaymentFieldData).type) { diff --git a/editor/components/extension/phone-country-picker.tsx b/editor/components/extension/phone-country-picker.tsx new file mode 100644 index 0000000000..85fc45fdb3 --- /dev/null +++ b/editor/components/extension/phone-country-picker.tsx @@ -0,0 +1,146 @@ +import * as React from "react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import * as RPNInput from "react-phone-number-input"; +import flags from "react-phone-number-input/flags"; + +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 { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/components/lib/utils"; + +/** + * ISO 3166-1 alpha-2 region code (e.g. "US", "KR"). + * + * Note: this is NOT the E.164 country calling code (+1, +82, ...). + */ +export type PhoneCountryCode = RPNInput.Country; + +export type PhoneCountryPickerOption = { + label: string; + value: PhoneCountryCode; +}; + +export type PhoneCountryPickerProps = { + disabled?: boolean; + value: PhoneCountryCode; + options: PhoneCountryPickerOption[]; + onChange: (country: PhoneCountryCode) => void; + className?: string; +}; + +interface PhoneCountryPickerItemProps extends RPNInput.FlagProps { + selectedCountry: PhoneCountryCode; + onChange: (country: PhoneCountryCode) => void; +} + +const PhoneCountryPickerItem = ({ + country, + countryName, + selectedCountry, + onChange, +}: PhoneCountryPickerItemProps) => { + return ( + onChange(country)}> + + {countryName} + {`+${RPNInput.getCountryCallingCode(country)}`} + + + ); +}; + +const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { + const Flag = flags[country]; + + return ( + + {Flag && ( + + )} + + ); +}; + +/** + * Phone country picker (searchable) that selects an ISO-3166-1 alpha-2 region, + * and displays the derived country calling code (+...). + */ +export function PhoneCountryPicker({ + disabled, + value: selectedCountry, + options: countryList, + onChange, + className, +}: PhoneCountryPickerProps) { + const selectedLabel = + countryList.find((c) => c.value === selectedCountry)?.label ?? + selectedCountry; + + return ( + + + + + + + + + + No country found. + + {countryList.map(({ value, label }) => ( + + ))} + + + + + + + ); +} + diff --git a/editor/components/formfield/form-field.tsx b/editor/components/formfield/form-field.tsx index e869b3ff6c..be5a47482e 100644 --- a/editor/components/formfield/form-field.tsx +++ b/editor/components/formfield/form-field.tsx @@ -5,6 +5,7 @@ import type { Option, FormFieldDataSchema, PaymentFieldData, + PhoneFieldData, } from "@/grida-forms-hosted/types"; import { Select as HtmlSelect } from "@/components/vanilla/select"; import { @@ -243,7 +244,6 @@ function MonoFormField({ <> {label || name} {src && ( - // eslint-disable-next-line @next/next/no-img-element {label)} + defaultCountry={defaultCountry as any} /> ); } @@ -665,7 +669,6 @@ function MonoFormField({ {item.label} {item.src && ( - // eslint-disable-next-line @next/next/no-img-element {item.label {option.src ? (
- {/* eslint-disable-next-line @next/next/no-img-element */} {option.label & { db_table_id: string; }; @@ -242,6 +250,23 @@ export function FieldEditPanel({ ); const [multiple, setMultiple] = useState(init?.multiple || false); + const phone_default_country_options = useMemo(() => { + let displayNames: Intl.DisplayNames | null = null; + try { + displayNames = + typeof Intl !== "undefined" && "DisplayNames" in Intl + ? new Intl.DisplayNames(["en"], { type: "region" }) + : null; + } catch { + displayNames = null; + } + + return phone_default_country_codes.map((code) => ({ + value: code, + label: displayNames?.of(code) ?? code, + })) satisfies PhoneCountryPickerOption[]; + }, []); + const [storage_enabled, __set_storage_enabled] = useState(!!init?.storage); const [storage, setStorage] = useState< Partial @@ -509,6 +534,60 @@ export function FieldEditPanel({ +