@traky-app/ui 1.6.3 → 1.6.4

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.
Files changed (60) hide show
  1. package/package.json +15 -1
  2. package/registry/traky/json/city-select.json +19 -0
  3. package/registry/traky/json/country-select.json +24 -0
  4. package/registry/traky/json/geo-search-select.json +24 -0
  5. package/registry/traky/json/geo-selectors.json +21 -0
  6. package/registry/traky/json/google-maps-address-picker.json +22 -0
  7. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  8. package/registry/traky/json/lib-geo-countries.json +14 -0
  9. package/registry/traky/json/lib-geo-csc.json +17 -0
  10. package/registry/traky/json/lib-geo-locations.json +14 -0
  11. package/registry/traky/json/lib-geo-overrides.json +14 -0
  12. package/registry/traky/json/lib-geo-types.json +14 -0
  13. package/registry/traky/json/lib-google-maps-address.json +14 -0
  14. package/registry/traky/json/lib-phone-countries.json +14 -0
  15. package/registry/traky/json/lib-phone-e164.json +14 -0
  16. package/registry/traky/json/lib-utils.json +1 -1
  17. package/registry/traky/json/office-address-form-dialog.json +17 -0
  18. package/registry/traky/json/office-address-form.json +31 -0
  19. package/registry/traky/json/phone-number-input.json +25 -0
  20. package/registry/traky/json/state-select.json +19 -0
  21. package/registry/traky/lib/geo-city-codes.ts +234 -0
  22. package/registry/traky/lib/geo-countries.ts +75 -0
  23. package/registry/traky/lib/geo-csc.ts +82 -0
  24. package/registry/traky/lib/geo-locations.ts +171 -0
  25. package/registry/traky/lib/geo-overrides.ts +375 -0
  26. package/registry/traky/lib/geo-types.ts +34 -0
  27. package/registry/traky/lib/google-maps-address.ts +100 -0
  28. package/registry/traky/lib/phone-countries.ts +380 -0
  29. package/registry/traky/lib/phone-e164.ts +66 -0
  30. package/registry/traky/lib/utils.ts +24 -0
  31. package/registry/traky/registry.json +180 -0
  32. package/registry/traky/ui/city-select.tsx +113 -0
  33. package/registry/traky/ui/country-select.tsx +233 -0
  34. package/registry/traky/ui/geo-search-select.tsx +136 -0
  35. package/registry/traky/ui/geo-selectors.tsx +162 -0
  36. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  37. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  38. package/registry/traky/ui/office-address-form.tsx +868 -0
  39. package/registry/traky/ui/phone-number-input.tsx +253 -0
  40. package/registry/traky/ui/state-select.tsx +107 -0
  41. package/src/lib/geo-city-codes.ts +234 -0
  42. package/src/lib/geo-countries.ts +75 -0
  43. package/src/lib/geo-csc.ts +82 -0
  44. package/src/lib/geo-locations.ts +171 -0
  45. package/src/lib/geo-overrides.ts +375 -0
  46. package/src/lib/geo-types.ts +34 -0
  47. package/src/lib/google-maps-address.ts +100 -0
  48. package/src/lib/phone-countries.ts +380 -0
  49. package/src/lib/phone-e164.ts +66 -0
  50. package/src/test/setup.ts +31 -0
  51. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  52. package/src/ui/city-select.tsx +113 -0
  53. package/src/ui/country-select.tsx +233 -0
  54. package/src/ui/geo-search-select.tsx +136 -0
  55. package/src/ui/geo-selectors.tsx +162 -0
  56. package/src/ui/google-maps-address-picker.tsx +422 -0
  57. package/src/ui/office-address-form-dialog.tsx +16 -0
  58. package/src/ui/office-address-form.tsx +877 -0
  59. package/src/ui/phone-number-input.tsx +253 -0
  60. package/src/ui/state-select.tsx +107 -0
@@ -183,6 +183,16 @@
183
183
  }
184
184
  ]
185
185
  },
186
+ {
187
+ "name": "city-select",
188
+ "type": "registry:ui",
189
+ "files": [
190
+ {
191
+ "path": "json/city-select.json",
192
+ "type": "registry:file"
193
+ }
194
+ ]
195
+ },
186
196
  {
187
197
  "name": "code-block",
188
198
  "type": "registry:ui",
@@ -223,6 +233,16 @@
223
233
  }
224
234
  ]
225
235
  },
236
+ {
237
+ "name": "country-select",
238
+ "type": "registry:ui",
239
+ "files": [
240
+ {
241
+ "path": "json/country-select.json",
242
+ "type": "registry:file"
243
+ }
244
+ ]
245
+ },
226
246
  {
227
247
  "name": "dialog",
228
248
  "type": "registry:ui",
@@ -283,6 +303,26 @@
283
303
  }
284
304
  ]
285
305
  },
306
+ {
307
+ "name": "geo-search-select",
308
+ "type": "registry:ui",
309
+ "files": [
310
+ {
311
+ "path": "json/geo-search-select.json",
312
+ "type": "registry:file"
313
+ }
314
+ ]
315
+ },
316
+ {
317
+ "name": "geo-selectors",
318
+ "type": "registry:ui",
319
+ "files": [
320
+ {
321
+ "path": "json/geo-selectors.json",
322
+ "type": "registry:file"
323
+ }
324
+ ]
325
+ },
286
326
  {
287
327
  "name": "globe",
288
328
  "type": "registry:ui",
@@ -293,6 +333,16 @@
293
333
  }
294
334
  ]
295
335
  },
336
+ {
337
+ "name": "google-maps-address-picker",
338
+ "type": "registry:ui",
339
+ "files": [
340
+ {
341
+ "path": "json/google-maps-address-picker.json",
342
+ "type": "registry:file"
343
+ }
344
+ ]
345
+ },
296
346
  {
297
347
  "name": "header-page",
298
348
  "type": "registry:ui",
@@ -473,6 +523,26 @@
473
523
  }
474
524
  ]
475
525
  },
526
+ {
527
+ "name": "office-address-form-dialog",
528
+ "type": "registry:ui",
529
+ "files": [
530
+ {
531
+ "path": "json/office-address-form-dialog.json",
532
+ "type": "registry:file"
533
+ }
534
+ ]
535
+ },
536
+ {
537
+ "name": "office-address-form",
538
+ "type": "registry:ui",
539
+ "files": [
540
+ {
541
+ "path": "json/office-address-form.json",
542
+ "type": "registry:file"
543
+ }
544
+ ]
545
+ },
476
546
  {
477
547
  "name": "office-switcher",
478
548
  "type": "registry:ui",
@@ -503,6 +573,16 @@
503
573
  }
504
574
  ]
505
575
  },
576
+ {
577
+ "name": "phone-number-input",
578
+ "type": "registry:ui",
579
+ "files": [
580
+ {
581
+ "path": "json/phone-number-input.json",
582
+ "type": "registry:file"
583
+ }
584
+ ]
585
+ },
506
586
  {
507
587
  "name": "popover",
508
588
  "type": "registry:ui",
@@ -593,6 +673,16 @@
593
673
  }
594
674
  ]
595
675
  },
676
+ {
677
+ "name": "state-select",
678
+ "type": "registry:ui",
679
+ "files": [
680
+ {
681
+ "path": "json/state-select.json",
682
+ "type": "registry:file"
683
+ }
684
+ ]
685
+ },
596
686
  {
597
687
  "name": "switch",
598
688
  "type": "registry:ui",
@@ -703,6 +793,96 @@
703
793
  }
704
794
  ]
705
795
  },
796
+ {
797
+ "name": "lib-geo-city-codes",
798
+ "type": "registry:lib",
799
+ "files": [
800
+ {
801
+ "path": "json/lib-geo-city-codes.json",
802
+ "type": "registry:file"
803
+ }
804
+ ]
805
+ },
806
+ {
807
+ "name": "lib-geo-countries",
808
+ "type": "registry:lib",
809
+ "files": [
810
+ {
811
+ "path": "json/lib-geo-countries.json",
812
+ "type": "registry:file"
813
+ }
814
+ ]
815
+ },
816
+ {
817
+ "name": "lib-geo-csc",
818
+ "type": "registry:lib",
819
+ "files": [
820
+ {
821
+ "path": "json/lib-geo-csc.json",
822
+ "type": "registry:file"
823
+ }
824
+ ]
825
+ },
826
+ {
827
+ "name": "lib-geo-locations",
828
+ "type": "registry:lib",
829
+ "files": [
830
+ {
831
+ "path": "json/lib-geo-locations.json",
832
+ "type": "registry:file"
833
+ }
834
+ ]
835
+ },
836
+ {
837
+ "name": "lib-geo-overrides",
838
+ "type": "registry:lib",
839
+ "files": [
840
+ {
841
+ "path": "json/lib-geo-overrides.json",
842
+ "type": "registry:file"
843
+ }
844
+ ]
845
+ },
846
+ {
847
+ "name": "lib-geo-types",
848
+ "type": "registry:lib",
849
+ "files": [
850
+ {
851
+ "path": "json/lib-geo-types.json",
852
+ "type": "registry:file"
853
+ }
854
+ ]
855
+ },
856
+ {
857
+ "name": "lib-google-maps-address",
858
+ "type": "registry:lib",
859
+ "files": [
860
+ {
861
+ "path": "json/lib-google-maps-address.json",
862
+ "type": "registry:file"
863
+ }
864
+ ]
865
+ },
866
+ {
867
+ "name": "lib-phone-countries",
868
+ "type": "registry:lib",
869
+ "files": [
870
+ {
871
+ "path": "json/lib-phone-countries.json",
872
+ "type": "registry:file"
873
+ }
874
+ ]
875
+ },
876
+ {
877
+ "name": "lib-phone-e164",
878
+ "type": "registry:lib",
879
+ "files": [
880
+ {
881
+ "path": "json/lib-phone-e164.json",
882
+ "type": "registry:file"
883
+ }
884
+ ]
885
+ },
706
886
  {
707
887
  "name": "lib-sidebar-persistence",
708
888
  "type": "registry:lib",
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { fetchCities } from "@/lib/geo-locations";
5
+ import type { GeoCityOption, GeoOption } from "@/lib/geo-types";
6
+ import { GeoSearchSelect } from "@/components/ui/geo-search-select";
7
+
8
+ export type CitySelectProps = {
9
+ id?: string;
10
+ /** ISO-2 del país. */
11
+ countryCode?: string | null;
12
+ /** Código de provincia / estado. */
13
+ stateCode?: string | null;
14
+ /** Código o nombre controlado. `undefined` deja el selector sin controlar; `""` lo deja vacío. */
15
+ value?: string;
16
+ /** Código o nombre inicial cuando `value` no se pasa. */
17
+ defaultValue?: string;
18
+ onChange?: (option: GeoCityOption | null) => void;
19
+ disabled?: boolean;
20
+ className?: string;
21
+ placeholder?: string;
22
+ showCode?: boolean;
23
+ };
24
+
25
+ export function CitySelect({
26
+ id,
27
+ countryCode,
28
+ stateCode,
29
+ value,
30
+ defaultValue,
31
+ onChange,
32
+ disabled = false,
33
+ className,
34
+ placeholder = "Buscar ciudad, pueblo o distrito…",
35
+ showCode = true,
36
+ }: CitySelectProps) {
37
+ const cc = countryCode?.trim().toUpperCase() ?? "";
38
+ const sc = stateCode?.trim() ?? "";
39
+ const ready = Boolean(cc && sc);
40
+ const isControlled = value !== undefined;
41
+ const [options, setOptions] = useState<GeoCityOption[]>([]);
42
+ const [isLoading, setIsLoading] = useState(false);
43
+ const [internal, setInternal] = useState(defaultValue ?? "");
44
+ const onChangeRef = useRef(onChange);
45
+ const controlledRef = useRef(isControlled);
46
+ const selectionRef = useRef("");
47
+ const prevContext = useRef({ cc, sc });
48
+
49
+ onChangeRef.current = onChange;
50
+ controlledRef.current = isControlled;
51
+ selectionRef.current = (isControlled ? value : internal) ?? "";
52
+
53
+ useEffect(() => {
54
+ const previous = prevContext.current;
55
+ if (previous.cc === cc && previous.sc === sc) return;
56
+ prevContext.current = { cc, sc };
57
+ if (!previous.cc || !previous.sc) return;
58
+ if (!controlledRef.current) setInternal("");
59
+ if (selectionRef.current) onChangeRef.current?.(null);
60
+ }, [cc, sc]);
61
+
62
+ useEffect(() => {
63
+ if (!ready) {
64
+ setOptions([]);
65
+ setIsLoading(false);
66
+ return;
67
+ }
68
+
69
+ let cancelled = false;
70
+ setOptions([]);
71
+ setIsLoading(true);
72
+ void fetchCities(cc, sc)
73
+ .then((list) => {
74
+ if (!cancelled) setOptions(list);
75
+ })
76
+ .finally(() => {
77
+ if (!cancelled) setIsLoading(false);
78
+ });
79
+
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [cc, sc, ready]);
84
+
85
+ const current = isControlled ? (value ?? "") : internal;
86
+ const selected = useMemo(() => {
87
+ if (!current) return undefined;
88
+ return options.find(
89
+ (option) => option.code === current || option.name === current,
90
+ );
91
+ }, [current, options]);
92
+
93
+ return (
94
+ <GeoSearchSelect
95
+ id={id}
96
+ className={className}
97
+ placeholder={ready ? placeholder : "Indica país y estado, región o municipio"}
98
+ searchPlaceholder="Buscar ciudad, pueblo o distrito…"
99
+ emptyMessage="Sin ciudades, pueblos ni distritos."
100
+ options={options}
101
+ value={selected?.code}
102
+ displayValue={selected?.name}
103
+ onChange={(option: GeoOption) => {
104
+ const city = option as GeoCityOption;
105
+ if (!isControlled) setInternal(city.code);
106
+ onChange?.(city);
107
+ }}
108
+ disabled={disabled || !ready}
109
+ isLoading={ready && isLoading}
110
+ showCode={showCode}
111
+ />
112
+ );
113
+ }
@@ -0,0 +1,233 @@
1
+ "use client";
2
+
3
+ import { IconCheck, IconSelector } from "@tabler/icons-react";
4
+
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { getGeoCountries } from "@/lib/geo-csc";
7
+ import { cn, commandFilterAccentInsensitive } from "@/lib/utils";
8
+ import {
9
+ getCountryByCode,
10
+ mapGeoCountries,
11
+ type CountryOption,
12
+ } from "@/lib/geo-countries";
13
+ import { Button } from "@/components/ui/button";
14
+ import {
15
+ Command,
16
+ CommandEmpty,
17
+ CommandGroup,
18
+ CommandInput,
19
+ CommandItem,
20
+ CommandList,
21
+ CommandSeparator,
22
+ } from "@/components/ui/command";
23
+ import {
24
+ Popover,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from "@/components/ui/popover";
28
+
29
+ export type Country = CountryOption;
30
+
31
+ function countryLabel(c: Country, size: "sm" | "md" | "lg"): string {
32
+ if (size === "sm") return c.short;
33
+ if (size === "md") return c.code;
34
+ return c.long;
35
+ }
36
+
37
+ /** Bandera local `/assets/flags/{ISO}.svg`; si no existe, no muestra nada. */
38
+ function CountryFlag({
39
+ code,
40
+ name,
41
+ }: {
42
+ code: string;
43
+ name: string;
44
+ }) {
45
+ const [visible, setVisible] = useState(true);
46
+ if (!visible || !code) return null;
47
+
48
+ return (
49
+ <img
50
+ src={`/assets/flags/${code.toUpperCase()}.svg`}
51
+ alt=""
52
+ title={name}
53
+ width={22}
54
+ height={14}
55
+ className="h-[14px] w-[22px] shrink-0 rounded object-cover"
56
+ onError={() => setVisible(false)}
57
+ />
58
+ );
59
+ }
60
+
61
+ export type CountrySelectProps = {
62
+ id?: string;
63
+ /** ISO-2 controlado. `undefined` deja el selector sin controlar; `""` lo deja vacío. */
64
+ value?: string;
65
+ /** ISO-2 inicial cuando `value` no se pasa. */
66
+ defaultValue?: string;
67
+ onChange?: (value: Country) => void;
68
+ /** Si se elige la opción vacía (requiere `allowClear`). */
69
+ onClear?: () => void;
70
+ disabled?: boolean;
71
+ fileName?: string;
72
+ className?: string;
73
+ size?: "sm" | "md" | "lg";
74
+ searchPlaceholder?: string;
75
+ /** Si se define, solo se listan estos ISO-2 (en este orden). */
76
+ allowedCodes?: readonly string[];
77
+ allowClear?: boolean;
78
+ clearLabel?: string;
79
+ };
80
+
81
+ export function CountrySelect({
82
+ id,
83
+ value,
84
+ defaultValue,
85
+ onChange,
86
+ onClear,
87
+ disabled,
88
+ fileName,
89
+ className,
90
+ size = "md",
91
+ searchPlaceholder = "Escribe o busca un país...",
92
+ allowedCodes,
93
+ allowClear = false,
94
+ clearLabel = "Sin país",
95
+ }: CountrySelectProps) {
96
+ const [open, setOpen] = useState(false);
97
+ const [catalog, setCatalog] = useState<Country[]>([]);
98
+ const [loadingCatalog, setLoadingCatalog] = useState(true);
99
+ const isControlled = value !== undefined;
100
+ const [uncontrolled, setUncontrolled] = useState<Country | undefined>(() =>
101
+ getCountryByCode(defaultValue),
102
+ );
103
+
104
+ useEffect(() => {
105
+ let cancelled = false;
106
+ setLoadingCatalog(true);
107
+ void (async () => {
108
+ const apiCountries = await getGeoCountries();
109
+ if (cancelled) return;
110
+ setCatalog(mapGeoCountries(apiCountries));
111
+ setLoadingCatalog(false);
112
+ })();
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, []);
117
+
118
+ const countries = useMemo(() => {
119
+ if (!allowedCodes?.length) return catalog;
120
+ const byCode = new Map(catalog.map((country) => [country.code, country]));
121
+ return allowedCodes
122
+ .map((code) => byCode.get(code.trim().toUpperCase()))
123
+ .filter((country): country is Country => Boolean(country));
124
+ }, [allowedCodes, catalog]);
125
+
126
+ const selected = useMemo(() => {
127
+ if (isControlled) {
128
+ const code = value?.trim().toUpperCase() ?? "";
129
+ if (!code) return undefined;
130
+ return catalog.find((c) => c.code === code) ?? getCountryByCode(code);
131
+ }
132
+ if (!uncontrolled) return undefined;
133
+ return catalog.find((c) => c.code === uncontrolled.code) ?? uncontrolled;
134
+ }, [catalog, isControlled, uncontrolled, value]);
135
+
136
+ const placeholder = fileName || "País";
137
+
138
+ const triggerLabel = useMemo(() => {
139
+ if (!selected) return null;
140
+ return countryLabel(selected, size);
141
+ }, [selected, size]);
142
+
143
+ const handleSelect = (country: Country) => {
144
+ if (!isControlled) setUncontrolled(country);
145
+ setOpen(false);
146
+ onChange?.(country);
147
+ };
148
+
149
+ const handleClear = () => {
150
+ if (!isControlled) setUncontrolled(undefined);
151
+ setOpen(false);
152
+ onClear?.();
153
+ };
154
+
155
+ return (
156
+ <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
157
+ <PopoverTrigger asChild>
158
+ <Button
159
+ id={id}
160
+ type="button"
161
+ variant="outline"
162
+ disabled={disabled}
163
+ role="combobox"
164
+ aria-expanded={open}
165
+ className={cn(
166
+ "h-10 w-full justify-between gap-2 font-normal",
167
+ className,
168
+ )}
169
+ >
170
+ {selected && triggerLabel ? (
171
+ <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
172
+ <CountryFlag code={selected.code} name={selected.long} />
173
+ <span className="truncate">{triggerLabel}</span>
174
+ </span>
175
+ ) : (
176
+ <span className="truncate text-muted-foreground">{placeholder}</span>
177
+ )}
178
+ <IconSelector className="size-4 shrink-0 opacity-50" />
179
+ </Button>
180
+ </PopoverTrigger>
181
+
182
+ {!disabled && (
183
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
184
+ <Command
185
+ className="border border-border"
186
+ filter={commandFilterAccentInsensitive}
187
+ >
188
+ <CommandInput placeholder={searchPlaceholder} />
189
+ <CommandList>
190
+ <CommandEmpty>
191
+ {loadingCatalog
192
+ ? "Cargando países…"
193
+ : "No se encontró el país."}
194
+ </CommandEmpty>
195
+ {allowClear ? (
196
+ <CommandGroup>
197
+ <CommandItem
198
+ value={`${clearLabel} sin pais none`}
199
+ onSelect={handleClear}
200
+ className="gap-2"
201
+ >
202
+ <span className="min-w-0 flex-1 truncate">{clearLabel}</span>
203
+ {!selected ? (
204
+ <IconCheck className="size-4 shrink-0 text-primary" />
205
+ ) : null}
206
+ </CommandItem>
207
+ </CommandGroup>
208
+ ) : null}
209
+ {allowClear ? <CommandSeparator /> : null}
210
+ <CommandGroup>
211
+ {countries.map((c) => (
212
+ <CommandItem
213
+ key={c.code}
214
+ value={`${c.long} ${c.code} ${c.short}`}
215
+ onSelect={() => handleSelect(c)}
216
+ className="gap-2"
217
+ >
218
+ <CountryFlag code={c.code} name={c.long} />
219
+ <span className="min-w-0 flex-1 truncate">{c.long}</span>
220
+ <span className="text-xs text-muted-foreground">{c.code}</span>
221
+ {selected?.code === c.code ? (
222
+ <IconCheck className="size-4 shrink-0 text-primary" />
223
+ ) : null}
224
+ </CommandItem>
225
+ ))}
226
+ </CommandGroup>
227
+ </CommandList>
228
+ </Command>
229
+ </PopoverContent>
230
+ )}
231
+ </Popover>
232
+ );
233
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { IconCheck, IconSelector } from "@tabler/icons-react";
4
+ import { useMemo, useState } from "react";
5
+ import { cn, commandFilterAccentInsensitive } from "@/lib/utils";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Command,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ } from "@/components/ui/command";
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ } from "@/components/ui/popover";
20
+ import { Spinner } from "@/components/ui/spinner";
21
+ import type { GeoOption } from "@/lib/geo-types";
22
+
23
+ type GeoSearchSelectProps = {
24
+ id?: string;
25
+ label?: string;
26
+ placeholder?: string;
27
+ searchPlaceholder?: string;
28
+ emptyMessage?: string;
29
+ options: GeoOption[];
30
+ value?: string;
31
+ displayValue?: string;
32
+ onChange?: (option: GeoOption) => void;
33
+ disabled?: boolean;
34
+ isLoading?: boolean;
35
+ className?: string;
36
+ showCode?: boolean;
37
+ };
38
+
39
+ export function GeoSearchSelect({
40
+ id,
41
+ placeholder = "Seleccionar…",
42
+ searchPlaceholder = "Buscar…",
43
+ emptyMessage = "No se encontraron resultados.",
44
+ options,
45
+ value,
46
+ displayValue,
47
+ onChange,
48
+ disabled = false,
49
+ isLoading = false,
50
+ className,
51
+ showCode = false,
52
+ }: GeoSearchSelectProps) {
53
+ const [open, setOpen] = useState(false);
54
+
55
+ const selected = useMemo(() => {
56
+ if (!value && !displayValue) return undefined;
57
+ if (value) {
58
+ return options.find((o) => o.code === value || o.name === value);
59
+ }
60
+ return options.find((o) => o.name === displayValue);
61
+ }, [options, value, displayValue]);
62
+
63
+ const triggerLabel = selected?.name ?? displayValue;
64
+
65
+ return (
66
+ <Popover modal open={open} onOpenChange={disabled ? undefined : setOpen}>
67
+ <PopoverTrigger asChild>
68
+ <Button
69
+ id={id}
70
+ type="button"
71
+ variant="outline"
72
+ disabled={disabled || isLoading}
73
+ role="combobox"
74
+ aria-expanded={open}
75
+ className={cn(
76
+ "h-10 w-full justify-between gap-2 font-normal",
77
+ className,
78
+ )}
79
+ >
80
+ {isLoading ? (
81
+ <span className="flex items-center gap-2 text-muted-foreground">
82
+ <Spinner className="size-4" />
83
+ Cargando…
84
+ </span>
85
+ ) : triggerLabel ? (
86
+ <span className="truncate">{triggerLabel}</span>
87
+ ) : (
88
+ <span className="truncate text-muted-foreground">{placeholder}</span>
89
+ )}
90
+ <IconSelector className="size-4 shrink-0 opacity-50" />
91
+ </Button>
92
+ </PopoverTrigger>
93
+
94
+ {!disabled && !isLoading && (
95
+ <PopoverContent
96
+ className="w-[var(--radix-popover-trigger-width)] p-0"
97
+ align="start"
98
+ >
99
+ <Command
100
+ className="border border-border"
101
+ filter={commandFilterAccentInsensitive}
102
+ >
103
+ <CommandInput placeholder={searchPlaceholder} />
104
+ <CommandList>
105
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
106
+ <CommandGroup>
107
+ {options.map((option) => (
108
+ <CommandItem
109
+ key={`${option.code}-${option.name}`}
110
+ value={`${option.name} ${option.code}`}
111
+ onSelect={() => {
112
+ onChange?.(option);
113
+ setOpen(false);
114
+ }}
115
+ className="gap-2"
116
+ >
117
+ <span className="min-w-0 flex-1 truncate">{option.name}</span>
118
+ {showCode ? (
119
+ <span className="text-xs text-muted-foreground">
120
+ {option.code}
121
+ </span>
122
+ ) : null}
123
+ {selected?.code === option.code &&
124
+ selected?.name === option.name ? (
125
+ <IconCheck className="size-4 shrink-0 text-primary" />
126
+ ) : null}
127
+ </CommandItem>
128
+ ))}
129
+ </CommandGroup>
130
+ </CommandList>
131
+ </Command>
132
+ </PopoverContent>
133
+ )}
134
+ </Popover>
135
+ );
136
+ }