@timeax/form-palette 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// src/presets/shadcn-variants/phone.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import type { VariantModule } from "@/schema/variant";
|
|
6
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
7
|
+
import type { ShadcnTextVariantProps } from "@/presets/shadcn-variants/text";
|
|
8
|
+
import { Input } from "@/presets/ui/input";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
} from "@/presets/ui/select";
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
17
|
+
import { getGlobalCountryList } from "@/lib/get-global-countries";
|
|
18
|
+
|
|
19
|
+
type BaseProps = VariantBaseProps<string | undefined>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Single-country phone config.
|
|
23
|
+
*
|
|
24
|
+
* - code: ISO 3166-1 alpha-2 ("NG", "US", "GB", ...)
|
|
25
|
+
* - dial: dial code without "+" ("234", "1", "44", ...)
|
|
26
|
+
* - mask: NATIONAL portion mask only (no dial), e.g. "999 999 9999"
|
|
27
|
+
*/
|
|
28
|
+
export interface PhoneCountry {
|
|
29
|
+
code: string;
|
|
30
|
+
label: string;
|
|
31
|
+
dial: string;
|
|
32
|
+
mask: string;
|
|
33
|
+
flag?: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How the variant emits the form value.
|
|
38
|
+
*
|
|
39
|
+
* - "masked" → "+234 801 234 5678"
|
|
40
|
+
* - "e164" → "2348012345678" (dial + national digits, no "+")
|
|
41
|
+
* - "national"→ "8012345678"
|
|
42
|
+
*/
|
|
43
|
+
export type PhoneValueMode = "masked" | "e164" | "national";
|
|
44
|
+
|
|
45
|
+
export interface PhoneSpecificProps {
|
|
46
|
+
countries?: PhoneCountry[];
|
|
47
|
+
defaultCountry?: string;
|
|
48
|
+
onCountryChange?: (country: PhoneCountry) => void;
|
|
49
|
+
|
|
50
|
+
showCountry?: boolean;
|
|
51
|
+
countryPlaceholder?: string;
|
|
52
|
+
showFlag?: boolean;
|
|
53
|
+
showSelectedLabel?: boolean;
|
|
54
|
+
showSelectedDial?: boolean;
|
|
55
|
+
showDialInList?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Controls how the emitted value is shaped.
|
|
59
|
+
*
|
|
60
|
+
* Default mirrors legacy autoUnmask=true + emitE164=true → "e164".
|
|
61
|
+
*/
|
|
62
|
+
valueMode?: PhoneValueMode;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* When true, the national mask keeps placeholder characters
|
|
66
|
+
* for not-yet-filled positions. When false, trailing mask
|
|
67
|
+
* fragments are omitted.
|
|
68
|
+
*/
|
|
69
|
+
keepCharPositions?: boolean;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Style hooks for the internal country selector.
|
|
73
|
+
*/
|
|
74
|
+
countrySelectClassName?: string;
|
|
75
|
+
countryTriggerClassName?: string;
|
|
76
|
+
countryValueClassName?: string;
|
|
77
|
+
countryContentClassName?: string;
|
|
78
|
+
countryItemClassName?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// We still *type* against ShadcnTextVariantProps so the phone variant exposes
|
|
82
|
+
// the same visual/text props (size, density, icon props, etc.), but we don't
|
|
83
|
+
// use the component itself anymore.
|
|
84
|
+
type TextUiProps = Omit<
|
|
85
|
+
ShadcnTextVariantProps,
|
|
86
|
+
// We control these for phone behaviour
|
|
87
|
+
"type" | "inputMode" | "leadingControl" | "value" | "onValue"
|
|
88
|
+
>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Full props for the phone variant as seen by the form runtime.
|
|
92
|
+
*
|
|
93
|
+
* - Keeps the same `value`/`onValue` contract as other variants.
|
|
94
|
+
* - Inherits visual/behavioural text props (size, density, className, etc.).
|
|
95
|
+
* - Adds phone-specific configuration (countries, valueMode, etc.).
|
|
96
|
+
*/
|
|
97
|
+
export type ShadcnPhoneVariantProps = TextUiProps &
|
|
98
|
+
PhoneSpecificProps &
|
|
99
|
+
Pick<BaseProps, "value" | "onValue">;
|
|
100
|
+
|
|
101
|
+
// ———————————————————————————————
|
|
102
|
+
// Defaults
|
|
103
|
+
// ———————————————————————————————
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
// ———————————————————————————————
|
|
108
|
+
// Mask helpers (lightweight legacy port)
|
|
109
|
+
// ———————————————————————————————
|
|
110
|
+
|
|
111
|
+
const TOKEN_CHARS = new Set(["9", "a", "*"] as const);
|
|
112
|
+
|
|
113
|
+
interface CompiledMask {
|
|
114
|
+
pattern: string;
|
|
115
|
+
placeholderChar: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Phone only ever really uses digit masks, so we keep this compact.
|
|
120
|
+
*/
|
|
121
|
+
function compileMask(pattern: string, placeholderChar = "_"): CompiledMask {
|
|
122
|
+
return { pattern, placeholderChar };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply a simple token-based mask:
|
|
127
|
+
* - '9' → digit
|
|
128
|
+
* - 'a' → letter
|
|
129
|
+
* - '*' → alphanumeric
|
|
130
|
+
*
|
|
131
|
+
* `keepCharPositions` keeps literal chars/placeholders even when not filled.
|
|
132
|
+
*/
|
|
133
|
+
function applyMask(
|
|
134
|
+
mask: CompiledMask,
|
|
135
|
+
raw: string,
|
|
136
|
+
keepCharPositions: boolean,
|
|
137
|
+
): string {
|
|
138
|
+
const { pattern, placeholderChar } = mask;
|
|
139
|
+
let result = "";
|
|
140
|
+
let rawIndex = 0;
|
|
141
|
+
const len = pattern.length;
|
|
142
|
+
|
|
143
|
+
const hasTokenAhead = (pos: number): boolean => {
|
|
144
|
+
for (let j = pos + 1; j < len; j++) {
|
|
145
|
+
if (TOKEN_CHARS.has(pattern[j] as any)) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < len; i++) {
|
|
151
|
+
const ch = pattern[i];
|
|
152
|
+
const isToken = TOKEN_CHARS.has(ch as any);
|
|
153
|
+
|
|
154
|
+
if (isToken) {
|
|
155
|
+
if (rawIndex >= raw.length) {
|
|
156
|
+
if (keepCharPositions) {
|
|
157
|
+
result += placeholderChar;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const next = raw[rawIndex++];
|
|
163
|
+
result += next;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Literal character in the mask.
|
|
168
|
+
const rawRemaining = rawIndex < raw.length;
|
|
169
|
+
const tokenAhead = hasTokenAhead(i);
|
|
170
|
+
|
|
171
|
+
// No tokens ahead → trailing literal.
|
|
172
|
+
if (!tokenAhead) {
|
|
173
|
+
if (keepCharPositions) {
|
|
174
|
+
result += ch;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (rawRemaining) {
|
|
181
|
+
// We still have digits to place → include the literal.
|
|
182
|
+
result += ch;
|
|
183
|
+
} else if (keepCharPositions) {
|
|
184
|
+
// No digits left, but want full skeleton.
|
|
185
|
+
result += ch;
|
|
186
|
+
} else {
|
|
187
|
+
// No digits left, and we don't keep skeleton → stop.
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Strip everything except digits.
|
|
197
|
+
*/
|
|
198
|
+
function digitsOnly(input: string | undefined | null): string {
|
|
199
|
+
return (input ?? "").replace(/\D+/g, "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ———————————————————————————————
|
|
203
|
+
// Value ↔ display helpers
|
|
204
|
+
// ———————————————————————————————
|
|
205
|
+
|
|
206
|
+
function dialPrefixFor(country: PhoneCountry): string {
|
|
207
|
+
return `+${country.dial} `;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* From any stored value (masked, e164, or national) extract
|
|
212
|
+
* the NATIONAL digits for a given country.
|
|
213
|
+
*
|
|
214
|
+
* Strategy: remove all non-digits, then strip leading dial code
|
|
215
|
+
* if present.
|
|
216
|
+
*/
|
|
217
|
+
function valueToNationalDigits(
|
|
218
|
+
value: string | undefined,
|
|
219
|
+
country: PhoneCountry,
|
|
220
|
+
): string {
|
|
221
|
+
const digits = digitsOnly(value);
|
|
222
|
+
if (!digits) return "";
|
|
223
|
+
if (digits.startsWith(country.dial)) {
|
|
224
|
+
return digits.slice(country.dial.length);
|
|
225
|
+
}
|
|
226
|
+
return digits;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the display string shown in the input for a given value.
|
|
231
|
+
*
|
|
232
|
+
* Always renders "+<dial> " plus an optionally masked national part.
|
|
233
|
+
*/
|
|
234
|
+
function computeDisplayFromValue(
|
|
235
|
+
value: string | undefined,
|
|
236
|
+
country: PhoneCountry,
|
|
237
|
+
keepCharPositions: boolean,
|
|
238
|
+
): string {
|
|
239
|
+
const prefix = dialPrefixFor(country);
|
|
240
|
+
|
|
241
|
+
const national = valueToNationalDigits(value, country);
|
|
242
|
+
if (!national) {
|
|
243
|
+
return prefix;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const mask = compileMask(country.mask);
|
|
247
|
+
const maskedNational = applyMask(mask, national, keepCharPositions);
|
|
248
|
+
if (!maskedNational) {
|
|
249
|
+
return prefix;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return prefix + maskedNational;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Given raw user input in the field, compute:
|
|
257
|
+
* - display string (what we show in the input)
|
|
258
|
+
* - next form value (according to valueMode)
|
|
259
|
+
* - nationalDigits (for metadata)
|
|
260
|
+
*/
|
|
261
|
+
function computeNextFromInput(
|
|
262
|
+
rawInput: string,
|
|
263
|
+
country: PhoneCountry,
|
|
264
|
+
mode: PhoneValueMode,
|
|
265
|
+
keepCharPositions: boolean,
|
|
266
|
+
): {
|
|
267
|
+
display: string;
|
|
268
|
+
nextValue: string | undefined;
|
|
269
|
+
nationalDigits: string;
|
|
270
|
+
} {
|
|
271
|
+
const prefix = dialPrefixFor(country);
|
|
272
|
+
const allDigits = digitsOnly(rawInput);
|
|
273
|
+
|
|
274
|
+
let national = allDigits;
|
|
275
|
+
if (national.startsWith(country.dial)) {
|
|
276
|
+
national = national.slice(country.dial.length);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const mask = compileMask(country.mask);
|
|
280
|
+
const maskedNational = applyMask(mask, national, keepCharPositions);
|
|
281
|
+
|
|
282
|
+
const display =
|
|
283
|
+
national.length === 0 ? prefix : (prefix + maskedNational || prefix);
|
|
284
|
+
|
|
285
|
+
let nextValue: string | undefined;
|
|
286
|
+
if (!national.length) {
|
|
287
|
+
nextValue = undefined;
|
|
288
|
+
} else if (mode === "masked") {
|
|
289
|
+
nextValue = display;
|
|
290
|
+
} else if (mode === "e164") {
|
|
291
|
+
nextValue = country.dial + national;
|
|
292
|
+
} else {
|
|
293
|
+
// "national"
|
|
294
|
+
nextValue = national;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { display, nextValue, nationalDigits: national };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* When the country changes, re-interpret the existing value's
|
|
302
|
+
* digits into the new country's mask/dial.
|
|
303
|
+
*/
|
|
304
|
+
function remapToCountry(
|
|
305
|
+
value: string | undefined,
|
|
306
|
+
from: PhoneCountry,
|
|
307
|
+
to: PhoneCountry,
|
|
308
|
+
mode: PhoneValueMode,
|
|
309
|
+
keepCharPositions: boolean,
|
|
310
|
+
): { display: string; nextValue: string | undefined } {
|
|
311
|
+
if (!value) {
|
|
312
|
+
const prefix = dialPrefixFor(to);
|
|
313
|
+
return { display: prefix, nextValue: undefined };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const digitsAll = digitsOnly(value);
|
|
317
|
+
|
|
318
|
+
let national = digitsAll;
|
|
319
|
+
if (digitsAll.startsWith(from.dial)) {
|
|
320
|
+
national = digitsAll.slice(from.dial.length);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const prefix = dialPrefixFor(to);
|
|
324
|
+
const mask = compileMask(to.mask);
|
|
325
|
+
const masked = applyMask(mask, national, keepCharPositions);
|
|
326
|
+
|
|
327
|
+
const display =
|
|
328
|
+
national.length === 0 ? prefix : (prefix + masked || prefix);
|
|
329
|
+
|
|
330
|
+
let nextValue: string | undefined;
|
|
331
|
+
if (!national.length) {
|
|
332
|
+
nextValue = undefined;
|
|
333
|
+
} else if (mode === "masked") {
|
|
334
|
+
nextValue = display;
|
|
335
|
+
} else if (mode === "e164") {
|
|
336
|
+
nextValue = to.dial + national;
|
|
337
|
+
} else {
|
|
338
|
+
nextValue = national;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { display, nextValue };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* If no placeholder is passed, we show the dial prefix plus an
|
|
346
|
+
* underscore-skeleton version of the national mask.
|
|
347
|
+
*/
|
|
348
|
+
function buildPlaceholder(country: PhoneCountry): string {
|
|
349
|
+
const prefix = dialPrefixFor(country);
|
|
350
|
+
const skeleton = country.mask.replace(/[9a\*]/g, "_");
|
|
351
|
+
return prefix + skeleton;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ———————————————————————————————
|
|
355
|
+
// Country select (Shadcn Select)
|
|
356
|
+
// ———————————————————————————————
|
|
357
|
+
|
|
358
|
+
interface CountrySelectProps {
|
|
359
|
+
countries: PhoneCountry[];
|
|
360
|
+
value: string;
|
|
361
|
+
onChange: (code: string) => void;
|
|
362
|
+
showFlag: boolean;
|
|
363
|
+
showSelectedLabel: boolean;
|
|
364
|
+
showSelectedDial: boolean;
|
|
365
|
+
showDialInList: boolean;
|
|
366
|
+
|
|
367
|
+
countrySelectClassName?: string;
|
|
368
|
+
countryTriggerClassName?: string;
|
|
369
|
+
countryValueClassName?: string;
|
|
370
|
+
countryContentClassName?: string;
|
|
371
|
+
countryItemClassName?: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const CountrySelect: React.FC<CountrySelectProps> = ({
|
|
375
|
+
countries,
|
|
376
|
+
value,
|
|
377
|
+
onChange,
|
|
378
|
+
showFlag,
|
|
379
|
+
showSelectedLabel,
|
|
380
|
+
showSelectedDial,
|
|
381
|
+
showDialInList,
|
|
382
|
+
countrySelectClassName,
|
|
383
|
+
countryTriggerClassName,
|
|
384
|
+
countryValueClassName,
|
|
385
|
+
countryContentClassName,
|
|
386
|
+
countryItemClassName,
|
|
387
|
+
}) => {
|
|
388
|
+
const selected =
|
|
389
|
+
countries.find((c) => c.code === value) ?? countries[0] ?? null;
|
|
390
|
+
|
|
391
|
+
const triggerLabel = selected
|
|
392
|
+
? [
|
|
393
|
+
showFlag && selected.flag ? selected.flag : null,
|
|
394
|
+
showSelectedDial ? `+${selected.dial}` : null,
|
|
395
|
+
showSelectedLabel ? selected.label : null,
|
|
396
|
+
]
|
|
397
|
+
.filter(Boolean)
|
|
398
|
+
.join(" ")
|
|
399
|
+
: "";
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<div className={countrySelectClassName}>
|
|
403
|
+
<Select value={selected?.code ?? ""} onValueChange={onChange}>
|
|
404
|
+
<SelectTrigger
|
|
405
|
+
className={cn(
|
|
406
|
+
"h-full min-w-18 px-2 focus-visible:ring-0 py-0 shadow-none rounded-none border-l-0 border-t-0 border-b-0 border-r text-xs whitespace-nowrap",
|
|
407
|
+
countryTriggerClassName,
|
|
408
|
+
)}
|
|
409
|
+
>
|
|
410
|
+
<SelectValue
|
|
411
|
+
placeholder="Code"
|
|
412
|
+
className={countryValueClassName}
|
|
413
|
+
>
|
|
414
|
+
{triggerLabel || selected?.code || "—"}
|
|
415
|
+
</SelectValue>
|
|
416
|
+
</SelectTrigger>
|
|
417
|
+
<SelectContent className={countryContentClassName}>
|
|
418
|
+
{countries.map((c) => {
|
|
419
|
+
const parts: string[] = [];
|
|
420
|
+
|
|
421
|
+
if (showFlag && c.flag) {
|
|
422
|
+
parts.push(String(c.flag));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (showDialInList) {
|
|
426
|
+
parts.push(`+${c.dial}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
parts.push(c.label);
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<SelectItem
|
|
433
|
+
key={c.code}
|
|
434
|
+
value={c.code}
|
|
435
|
+
className={countryItemClassName}
|
|
436
|
+
>
|
|
437
|
+
{parts.join(" ")}
|
|
438
|
+
</SelectItem>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</SelectContent>
|
|
442
|
+
</Select>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
// ———————————————————————————————
|
|
451
|
+
// Main variant component
|
|
452
|
+
// ———————————————————————————————
|
|
453
|
+
|
|
454
|
+
export const ShadcnPhoneVariant = React.forwardRef<
|
|
455
|
+
HTMLInputElement,
|
|
456
|
+
ShadcnPhoneVariantProps
|
|
457
|
+
>(function ShadcnPhoneVariant(props, ref) {
|
|
458
|
+
const {
|
|
459
|
+
countries: countriesProp,
|
|
460
|
+
defaultCountry,
|
|
461
|
+
onCountryChange,
|
|
462
|
+
showCountry = true,
|
|
463
|
+
showFlag = true,
|
|
464
|
+
showSelectedLabel = false,
|
|
465
|
+
showSelectedDial = false,
|
|
466
|
+
showDialInList = true,
|
|
467
|
+
valueMode = "e164",
|
|
468
|
+
keepCharPositions = false,
|
|
469
|
+
value,
|
|
470
|
+
onValue,
|
|
471
|
+
countryPlaceholder: placeholder,
|
|
472
|
+
error,
|
|
473
|
+
|
|
474
|
+
countrySelectClassName,
|
|
475
|
+
countryTriggerClassName,
|
|
476
|
+
countryValueClassName,
|
|
477
|
+
countryContentClassName,
|
|
478
|
+
countryItemClassName,
|
|
479
|
+
|
|
480
|
+
...restTextProps
|
|
481
|
+
} = props;
|
|
482
|
+
|
|
483
|
+
let DEFAULT_COUNTRIES = getGlobalCountryList();
|
|
484
|
+
const countries =
|
|
485
|
+
countriesProp && countriesProp.length > 0
|
|
486
|
+
? countriesProp
|
|
487
|
+
: DEFAULT_COUNTRIES;
|
|
488
|
+
|
|
489
|
+
const [country, setCountry] = React.useState<PhoneCountry>(() => {
|
|
490
|
+
if (defaultCountry) {
|
|
491
|
+
const found = countries.find((c) => c.code === defaultCountry);
|
|
492
|
+
if (found) return found;
|
|
493
|
+
}
|
|
494
|
+
return countries[0] ?? DEFAULT_COUNTRIES[0];
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Keep active country in sync if list/default changes.
|
|
498
|
+
React.useEffect(() => {
|
|
499
|
+
setCountry((prev) => {
|
|
500
|
+
if (defaultCountry) {
|
|
501
|
+
const found = countries.find((c) => c.code === defaultCountry);
|
|
502
|
+
if (found) return found;
|
|
503
|
+
}
|
|
504
|
+
const stillThere = countries.find((c) => c.code === prev.code);
|
|
505
|
+
return stillThere ?? countries[0] ?? prev;
|
|
506
|
+
});
|
|
507
|
+
}, [countries, defaultCountry]);
|
|
508
|
+
|
|
509
|
+
const [local, setLocal] = React.useState<string>(() =>
|
|
510
|
+
computeDisplayFromValue(value, country, keepCharPositions),
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Sync local display when external value or country changes.
|
|
514
|
+
React.useEffect(() => {
|
|
515
|
+
setLocal(computeDisplayFromValue(value, country, keepCharPositions));
|
|
516
|
+
}, [value, country, keepCharPositions]);
|
|
517
|
+
|
|
518
|
+
const handleInputChange = React.useCallback(
|
|
519
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
520
|
+
const rawInput = event.target.value ?? "";
|
|
521
|
+
const { display, nextValue, nationalDigits } = computeNextFromInput(
|
|
522
|
+
rawInput,
|
|
523
|
+
country,
|
|
524
|
+
valueMode,
|
|
525
|
+
keepCharPositions,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
setLocal(display);
|
|
529
|
+
|
|
530
|
+
if (onValue) {
|
|
531
|
+
const detail: ChangeDetail<{
|
|
532
|
+
country: PhoneCountry;
|
|
533
|
+
nationalDigits: string;
|
|
534
|
+
}> = {
|
|
535
|
+
source: "variant",
|
|
536
|
+
raw: rawInput,
|
|
537
|
+
nativeEvent: event,
|
|
538
|
+
meta: {
|
|
539
|
+
country,
|
|
540
|
+
nationalDigits,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
onValue(nextValue, detail);
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
[country, valueMode, keepCharPositions, onValue],
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const handleCountryChange = React.useCallback(
|
|
550
|
+
(nextCode: string) => {
|
|
551
|
+
const nextCountry =
|
|
552
|
+
countries.find((c) => c.code === nextCode) ?? countries[0];
|
|
553
|
+
|
|
554
|
+
if (!nextCountry) return;
|
|
555
|
+
|
|
556
|
+
setCountry(nextCountry);
|
|
557
|
+
onCountryChange?.(nextCountry);
|
|
558
|
+
|
|
559
|
+
const { display, nextValue } = remapToCountry(
|
|
560
|
+
value,
|
|
561
|
+
country,
|
|
562
|
+
nextCountry,
|
|
563
|
+
valueMode,
|
|
564
|
+
keepCharPositions,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
setLocal(display);
|
|
568
|
+
|
|
569
|
+
if (onValue) {
|
|
570
|
+
const detail: ChangeDetail<{
|
|
571
|
+
from: PhoneCountry;
|
|
572
|
+
to: PhoneCountry;
|
|
573
|
+
}> = {
|
|
574
|
+
source: "variant",
|
|
575
|
+
raw: undefined,
|
|
576
|
+
meta: {
|
|
577
|
+
from: country,
|
|
578
|
+
to: nextCountry,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
onValue(nextValue, detail);
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
[
|
|
585
|
+
countries,
|
|
586
|
+
country,
|
|
587
|
+
keepCharPositions,
|
|
588
|
+
onCountryChange,
|
|
589
|
+
onValue,
|
|
590
|
+
value,
|
|
591
|
+
valueMode,
|
|
592
|
+
],
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const effectivePlaceholder =
|
|
596
|
+
placeholder ?? buildPlaceholder(country);
|
|
597
|
+
|
|
598
|
+
const leadingControl = showCountry ? (
|
|
599
|
+
<CountrySelect
|
|
600
|
+
countries={countries}
|
|
601
|
+
value={country.code}
|
|
602
|
+
onChange={handleCountryChange}
|
|
603
|
+
showFlag={showFlag}
|
|
604
|
+
showSelectedLabel={showSelectedLabel}
|
|
605
|
+
showSelectedDial={showSelectedDial}
|
|
606
|
+
showDialInList={showDialInList}
|
|
607
|
+
countrySelectClassName={countrySelectClassName}
|
|
608
|
+
countryTriggerClassName={countryTriggerClassName}
|
|
609
|
+
countryValueClassName={countryValueClassName}
|
|
610
|
+
countryContentClassName={countryContentClassName}
|
|
611
|
+
countryItemClassName={countryItemClassName}
|
|
612
|
+
/>
|
|
613
|
+
) : undefined;
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<Input
|
|
617
|
+
ref={ref}
|
|
618
|
+
{...restTextProps}
|
|
619
|
+
type="tel"
|
|
620
|
+
inputMode="tel"
|
|
621
|
+
value={local}
|
|
622
|
+
onChange={handleInputChange}
|
|
623
|
+
leadingControl={leadingControl}
|
|
624
|
+
placeholder={effectivePlaceholder}
|
|
625
|
+
aria-invalid={error ? "true" : undefined}
|
|
626
|
+
/>
|
|
627
|
+
);
|
|
628
|
+
});
|