Skip to content

Commit 5ccbc7a

Browse files
feat(registry): add phone-input (#257)
* chore: cleanup * feat: add phone input component with country selector Add a new phone input component with international phone number support: - Country dropdown with flags and dial codes (50+ countries) - Built-in search using Command component with keyboard navigation - Form integration with React Hook Form - Both controlled and uncontrolled patterns - Custom country list support - Full accessibility with ARIA attributes Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: add PhoneInputGroup for input-group pattern Refactor phone input to use an input-group pattern similar to shadcn: - Add PhoneInputGroup component that handles flex layout and border styling - Remove need for manual div wrapper in demos - Country select and field are now direct children of PhoneInputGroup - Focus states and borders handled by the group container - Cleaner API with better component composition Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify phone input API - remove Label and Group Remove PhoneInputLabel and PhoneInputGroup for a cleaner API: - PhoneInput now acts as both root container and input group - Handles layout, borders, and focus states directly - Labels can be added externally as needed (standard label or FormLabel) - Reduced from 5 components to 3: PhoneInput, CountrySelect, Field - Simpler composition with less nesting Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove orphaned aria-controls attribute Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove unused aria attributes from country select Co-authored-by: Cursor <cursoragent@cursor.com> * fix: make country select full height with proper border separator Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: improve phone input focus states and use shadcn Input - Use shadcn Input component for consistent styling - Individual focus rings for country selector and input field - Each segment now has its own clear focus state (not entire container) - Match shadcn input-group focus ring pattern (3px ring with 50% opacity) - Remove overflow-hidden to prevent focus ring clipping - Add proper invalid state styling for both segments * feat: focus input field after country selection * refactor: clean up phone input component - Simplify PhoneInputProps to extend React.ComponentProps directly - Allow PhoneInputCountrySelect to accept all PopoverTrigger props - Remove unnecessary interface definitions and inline prop types - Simplify ref handling in PhoneInputField - Remove redundant comments * feat: add locale detection for automatic country selection - Remove hardcoded default country (previously "US") - Add getCountryFromLocale utility function - Add locale prop to PhoneInputProps - Automatically detect country from browser locale (e.g., en-US → US) - Falls back to first country in list if detection fails - Update demo to showcase locale detection - Add documentation for locale detection behavior * feat: add autoDetect prop to disable automatic country selection - Add autoDetect prop (default: true) to PhoneInputProps - When false, no country is pre-selected, forcing explicit selection - Update initialization logic to respect autoDetect flag - Add new demo example showcasing disabled auto-detection - Update documentation with autoDetect usage * chore: rebuild registry * chore: sort DEFAULT_COUNTRIES alphabetically by country name * fix: prevent hydration mismatch in phone input locale detection - Skip locale detection during SSR to ensure consistent server/client rendering - Add useEffect to detect and set locale on client after hydration - Prevents mismatch between server-rendered and client-rendered country - Fixes hydration error when autoDetect is enabled without defaultCountry * fix: ensure consistent initial state for SSR hydration * fix: add useEffect to detect locale after hydration * fix: run locale detection only once on mount * refactor: improve phone input state management and API - Move open/onOpenChange to store for consistent state management - Change PhoneInputCountrySelect to extend Popover props instead of PopoverTrigger - Rename isLoadingCountry to isLoading for brevity - Add loading skeleton for locale detection on client hydration - Fix SSR hydration mismatch by deferring locale detection to useEffect * feat: add controlled/uncontrolled open state to PhoneInputCountrySelect * fix: compose external onOpenChange with internal handler * fix: compose external onOpenChange with internal handler * chore: rebuild registry * refactor: improve phone input loading and empty states - Simplify locale detection logic with isLoading guard clause - Add placeholder text when no country is selected to prevent layout shift - Clean up conditional rendering in country select trigger * feat: use placeholder block instead of text when no country selected * refactor: improve phone input state management and loading UX - Move open state to store for centralized state management - Compose external onOpenChange with internal handler - Add loading skeleton during locale detection to prevent hydration mismatch - Add placeholder block when no country selected to prevent layout shift - Adjust placeholder widths based on showFlag/showDialCode props - Simplify locale detection with isLoading guard clause - Fix SSR hydration by deferring locale detection to useEffect * refactor: optimize phone input by removing external dependencies Replace libphonenumber-js, country-telephone-data, and flag icon libraries with lightweight inline data and native browser APIs. This reduces bundle size by ~2-4MB while maintaining all functionality. Changes: - Remove libphonenumber-js dependency (~200KB) - Remove country-telephone-data dependency (~20KB) - Remove unused country-flag-icons and flag-icons libraries (~2-4MB) - Inline country data as compact [iso2, dialCode] tuples (~5KB) - Use native Intl.DisplayNames API for country names - Generate flag emojis programmatically from country codes - Update registry dependencies to reflect removed packages * docs: add attribution for country data source * chore: cleanup * chore: rebuild registry again * fix: use correct KeyboardShortcutsTable component in docs * chore: fix docs * chore: better value --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 89a4d9a commit 5ccbc7a

18 files changed

+1685
-14
lines changed

docs/__registry__/index.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,28 @@ export const Index: Record<string, any> = {
417417
source: "",
418418
chunks: []
419419
},
420+
"phone-input": {
421+
name: "phone-input",
422+
description: "",
423+
type: "registry:ui",
424+
registryDependencies: ["command","input","popover","@diceui/use-as-ref","@diceui/use-isomorphic-layout-effect","@diceui/use-lazy-ref"],
425+
files: [{
426+
path: "registry/default/ui/phone-input.tsx",
427+
type: "registry:ui",
428+
target: ""
429+
},{
430+
path: "registry/default/components/visually-hidden-input.tsx",
431+
type: "registry:component",
432+
target: ""
433+
},{
434+
path: "registry/default/lib/compose-refs.ts",
435+
type: "registry:lib",
436+
target: ""
437+
}],
438+
component: React.lazy(() => import("@/registry/default/ui/phone-input.tsx")),
439+
source: "",
440+
chunks: []
441+
},
420442
"relative-time-card": {
421443
name: "relative-time-card",
422444
description: "",
@@ -2341,6 +2363,62 @@ export const Index: Record<string, any> = {
23412363
source: "",
23422364
chunks: []
23432365
},
2366+
"phone-input-demo": {
2367+
name: "phone-input-demo",
2368+
description: "",
2369+
type: "registry:example",
2370+
registryDependencies: ["phone-input"],
2371+
files: [{
2372+
path: "registry/default/examples/phone-input-demo.tsx",
2373+
type: "registry:example",
2374+
target: ""
2375+
}],
2376+
component: React.lazy(() => import("@/registry/default/examples/phone-input-demo.tsx")),
2377+
source: "",
2378+
chunks: []
2379+
},
2380+
"phone-input-form-demo": {
2381+
name: "phone-input-form-demo",
2382+
description: "",
2383+
type: "registry:example",
2384+
registryDependencies: ["phone-input","button","form"],
2385+
files: [{
2386+
path: "registry/default/examples/phone-input-form-demo.tsx",
2387+
type: "registry:example",
2388+
target: ""
2389+
}],
2390+
component: React.lazy(() => import("@/registry/default/examples/phone-input-form-demo.tsx")),
2391+
source: "",
2392+
chunks: []
2393+
},
2394+
"phone-input-custom-countries-demo": {
2395+
name: "phone-input-custom-countries-demo",
2396+
description: "",
2397+
type: "registry:example",
2398+
registryDependencies: ["phone-input"],
2399+
files: [{
2400+
path: "registry/default/examples/phone-input-custom-countries-demo.tsx",
2401+
type: "registry:example",
2402+
target: ""
2403+
}],
2404+
component: React.lazy(() => import("@/registry/default/examples/phone-input-custom-countries-demo.tsx")),
2405+
source: "",
2406+
chunks: []
2407+
},
2408+
"phone-input-no-auto-detect-demo": {
2409+
name: "phone-input-no-auto-detect-demo",
2410+
description: "",
2411+
type: "registry:example",
2412+
registryDependencies: ["phone-input"],
2413+
files: [{
2414+
path: "registry/default/examples/phone-input-no-auto-detect-demo.tsx",
2415+
type: "registry:example",
2416+
target: ""
2417+
}],
2418+
component: React.lazy(() => import("@/registry/default/examples/phone-input-no-auto-detect-demo.tsx")),
2419+
source: "",
2420+
chunks: []
2421+
},
23442422
"qr-code-demo": {
23452423
name: "qr-code-demo",
23462424
description: "",

docs/app/(lobby)/pg/page.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
11
import { Demo } from "@/components/demo";
22
import { Shell } from "@/components/shell";
3-
import { Button } from "@/components/ui/button";
43
import ColorPickerDemo from "@/registry/default/examples/color-picker-demo";
5-
import ResponsiveDialogConfirmDemo from "@/registry/default/examples/responsive-dialog-confirm-demo";
6-
import ResponsiveDialogDemo from "@/registry/default/examples/responsive-dialog-demo";
7-
import SelectionToolbarDemo from "@/registry/default/examples/selection-toolbar-demo";
8-
import SelectionToolbarInfoDemo from "@/registry/default/examples/selection-toolbar-info-demo";
9-
import SwapAnimationsDemo from "@/registry/default/examples/swap-animations-demo";
10-
import SwapDemo from "@/registry/default/examples/swap-demo";
4+
import PhoneInputDemo from "@/registry/default/examples/phone-input-demo";
115

126
export default function PlaygroundPage() {
137
return (
148
<Shell>
159
<Demo>
16-
<ResponsiveDialogDemo />
17-
<ResponsiveDialogConfirmDemo />
18-
<SwapDemo />
19-
<SwapAnimationsDemo />
20-
<SelectionToolbarDemo />
21-
<Button>Do a kickflip</Button>
22-
<SelectionToolbarInfoDemo />
10+
<PhoneInputDemo />
2311
<ColorPickerDemo />
2412
</Demo>
2513
</Shell>
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
title: Phone Input
3+
description: An accessible phone input component with country code dropdown and international phone number support.
4+
preview: true
5+
links:
6+
api: /docs/components/phone-input#api-reference
7+
---
8+
9+
<ComponentTabs name="phone-input-demo" className="items-start justify-start [&>div]:pt-20" />
10+
11+
## Installation
12+
13+
### CLI
14+
15+
```package-install
16+
npx shadcn@latest add @diceui/phone-input
17+
```
18+
19+
### Manual
20+
21+
<Steps>
22+
<Step>
23+
Install the following dependencies:
24+
25+
```package-install
26+
@radix-ui/react-slot lucide-react
27+
```
28+
</Step>
29+
<Step>
30+
Copy and paste the refs composition utilities into your `lib/compose-refs.ts` file.
31+
32+
<ComponentSource name="compose-refs" />
33+
</Step>
34+
<Step>
35+
Copy and paste the visually hidden input component into your `components/visually-hidden-input.tsx` file.
36+
37+
<ComponentSource name="visually-hidden-input" />
38+
</Step>
39+
<Step>
40+
Copy and paste the following hooks into your `hooks` directory.
41+
42+
<ComponentSource name="use-as-ref" />
43+
<ComponentSource name="use-isomorphic-layout-effect" />
44+
<ComponentSource name="use-lazy-ref" />
45+
</Step>
46+
<Step>
47+
Copy and paste the following code into your project.
48+
49+
<ComponentSource name="phone-input" />
50+
</Step>
51+
<Step>
52+
Update the import paths to match your project setup.
53+
</Step>
54+
</Steps>
55+
56+
## Layout
57+
58+
Import the parts, and compose them together.
59+
60+
```tsx
61+
import {
62+
PhoneInput,
63+
PhoneInputCountrySelect,
64+
PhoneInputField,
65+
} from "@/components/ui/phone-input";
66+
67+
return (
68+
<PhoneInput>
69+
<PhoneInputCountrySelect />
70+
<PhoneInputField />
71+
</PhoneInput>
72+
)
73+
```
74+
75+
## Locale Detection
76+
77+
The phone input automatically detects the user's country based on their browser locale. You can override this behavior by providing a `defaultCountry` or `locale` prop.
78+
79+
```tsx
80+
// Automatically detects from browser locale (e.g., en-US → US)
81+
<PhoneInput>
82+
<PhoneInputCountrySelect />
83+
<PhoneInputField />
84+
</PhoneInput>
85+
86+
// Override with a specific country
87+
<PhoneInput defaultCountry="GB">
88+
<PhoneInputCountrySelect />
89+
<PhoneInputField />
90+
</PhoneInput>
91+
92+
// Use a specific locale for detection
93+
<PhoneInput locale="en-GB">
94+
<PhoneInputCountrySelect />
95+
<PhoneInputField />
96+
</PhoneInput>
97+
98+
// Disable auto-detection, force users to select
99+
<PhoneInput autoDetect={false}>
100+
<PhoneInputCountrySelect />
101+
<PhoneInputField />
102+
</PhoneInput>
103+
```
104+
105+
If no country can be detected from the locale, it defaults to the first country in the `countries` list.
106+
107+
## Examples
108+
109+
### Custom Countries
110+
111+
Provide a custom list of countries to display in the dropdown.
112+
113+
<ComponentTabs name="phone-input-custom-countries-demo" className="items-start justify-start [&>div]:pt-20" />
114+
115+
### No Auto-Detection
116+
117+
Disable automatic country detection to force users to explicitly select a country.
118+
119+
<ComponentTabs name="phone-input-no-auto-detect-demo" className="items-start justify-start [&>div]:pt-20" />
120+
121+
### With Form
122+
123+
Use the phone input component in a form with validation.
124+
125+
<ComponentTabs name="phone-input-form-demo" className="items-start justify-start [&>div]:pt-20" />
126+
127+
## API Reference
128+
129+
### PhoneInput
130+
131+
The root container component that acts as both the wrapper and input group. Handles layout, borders, and focus states.
132+
133+
<AutoTypeTable
134+
path="./types/docs/phone-input.ts"
135+
name="PhoneInputProps"
136+
/>
137+
138+
<DataAttributesTable
139+
data={[
140+
{
141+
title: "[data-disabled]",
142+
value: "Present when the phone input is disabled",
143+
},
144+
{
145+
title: "[data-invalid]",
146+
value: "Present when the phone input is invalid",
147+
},
148+
{
149+
title: "[data-readonly]",
150+
value: "Present when the phone input is read-only",
151+
},
152+
{
153+
title: "[data-slot]",
154+
value: "phone-input",
155+
},
156+
]}
157+
/>
158+
159+
### PhoneInputCountrySelect
160+
161+
The button component that triggers the country dropdown. Uses Popover and Command internally for the country list.
162+
163+
<AutoTypeTable
164+
path="./types/docs/phone-input.ts"
165+
name="PhoneInputCountrySelectProps"
166+
/>
167+
168+
<DataAttributesTable
169+
data={[
170+
{
171+
title: "[data-slot]",
172+
value: "phone-input-country-select",
173+
},
174+
]}
175+
/>
176+
177+
### PhoneInputField
178+
179+
The input field component for entering the phone number.
180+
181+
<AutoTypeTable
182+
path="./types/docs/phone-input.ts"
183+
name="PhoneInputFieldProps"
184+
/>
185+
186+
<DataAttributesTable
187+
data={[
188+
{
189+
title: "[data-slot]",
190+
value: "phone-input-field",
191+
},
192+
]}
193+
/>
194+
195+
### Country Type
196+
197+
The country object type used throughout the component.
198+
199+
```tsx
200+
interface Country {
201+
code: string; // ISO 3166-1 alpha-2 country code
202+
name: string; // Country name
203+
dialCode: string; // Country calling code (e.g., "+1")
204+
flag?: string; // Optional flag emoji
205+
}
206+
```
207+
208+
## Accessibility
209+
210+
### Keyboard Interactions
211+
212+
<KeyboardShortcutsTable
213+
data={[
214+
{
215+
keys: ["Tab"],
216+
description:
217+
"Moves focus to the next focusable element (country select or phone input field).",
218+
},
219+
{
220+
keys: ["Space", "Enter"],
221+
description: "Opens the country dropdown when focused on the country select button.",
222+
},
223+
{
224+
keys: ["Escape"],
225+
description: "Closes the country dropdown.",
226+
},
227+
{
228+
keys: ["ArrowUp", "ArrowDown"],
229+
description:
230+
"Navigate through country items when the dropdown is open.",
231+
},
232+
{
233+
keys: ["Home", "End"],
234+
description:
235+
"Jump to first or last country in the list.",
236+
},
237+
{
238+
keys: ["Type to search"],
239+
description:
240+
"Filter countries by name, dial code, or country code as you type.",
241+
},
242+
]}
243+
/>

docs/public/r/index.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,35 @@
519519
],
520520
"type": "registry:ui"
521521
},
522+
{
523+
"name": "phone-input",
524+
"dependencies": [
525+
"@radix-ui/react-slot"
526+
],
527+
"registryDependencies": [
528+
"command",
529+
"input",
530+
"popover",
531+
"@diceui/use-as-ref",
532+
"@diceui/use-isomorphic-layout-effect",
533+
"@diceui/use-lazy-ref"
534+
],
535+
"files": [
536+
{
537+
"path": "ui/phone-input.tsx",
538+
"type": "registry:ui"
539+
},
540+
{
541+
"path": "components/visually-hidden-input.tsx",
542+
"type": "registry:component"
543+
},
544+
{
545+
"path": "lib/compose-refs.ts",
546+
"type": "registry:lib"
547+
}
548+
],
549+
"type": "registry:ui"
550+
},
522551
{
523552
"name": "relative-time-card",
524553
"dependencies": [

0 commit comments

Comments
 (0)