@traky-app/ui 1.5.0 → 1.6.0
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/package.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "option-combobox",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"popover"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "ui/option-combobox.tsx",
|
|
17
|
+
"content": "\"use client\"\n\nimport { useState, type ComponentType } from \"react\"\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\"\n\nexport type OptionComboboxOption = {\n value: string\n label: string\n icon?: ComponentType<{ className?: string; stroke?: number }>\n iconClassName?: string\n /** Pastilla de color, por ejemplo una etiqueta. */\n color?: string\n /** Sangría para una opción hija. */\n indented?: boolean\n}\n\nfunction OptionGlyph({\n icon: Icon,\n className,\n}: {\n icon?: OptionComboboxOption[\"icon\"]\n className?: string\n}) {\n if (!Icon) return null\n return <Icon className={cn(\"size-4 shrink-0\", className)} stroke={1.75} />\n}\n\nfunction OptionFace({ option }: { option: OptionComboboxOption }) {\n if (option.color) {\n return (\n <span\n className=\"inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-xs font-medium\"\n style={{\n color: option.color,\n borderColor: option.color,\n backgroundColor: `${option.color}1A`,\n }}\n >\n {option.label}\n </span>\n )\n }\n return (\n <span className=\"flex min-w-0 items-center gap-2\">\n <OptionGlyph icon={option.icon} className={option.iconClassName} />\n <span className=\"truncate\">{option.label}</span>\n </span>\n )\n}\n\nexport function OptionCombobox({\n value = \"\",\n options,\n onChange,\n placeholder = \"Seleccionar…\",\n searchPlaceholder = \"Buscar…\",\n emptyLabel = \"No se encontraron resultados.\",\n groupHeading,\n allowEmpty = false,\n emptyValueLabel,\n emptyValueIcon,\n clearLabel,\n clearOption,\n allOption,\n groups,\n multiple = false,\n values = [],\n onValuesChange,\n className,\n disabled = false,\n modal = false,\n title,\n}: {\n value?: string\n options: OptionComboboxOption[]\n onChange?: (value: string) => void\n /** Varias opciones a la vez. El menú no se cierra al marcar. */\n multiple?: boolean\n values?: string[]\n onValuesChange?: (values: string[]) => void\n placeholder?: string\n searchPlaceholder?: string\n emptyLabel?: string\n groupHeading?: string\n /** Si es true, la primera fila deja el valor vacío y no pertenece a `options`. */\n allowEmpty?: boolean\n /** Texto de esa fila vacía. Si no se indica, usa `placeholder`. */\n emptyValueLabel?: string\n emptyValueIcon?: OptionComboboxOption[\"icon\"]\n /** Opción para vaciar el valor. El botón sigue mostrando `placeholder`. */\n clearLabel?: string\n /** Opción para vaciar el valor, visible también en el botón. */\n clearOption?: OptionComboboxOption\n /** Opción extra al inicio. Si no hay valor, el botón la muestra. */\n allOption?: OptionComboboxOption\n /** Varios grupos, por ejemplo persona y empresa. */\n groups?: { heading?: string; options: OptionComboboxOption[] }[]\n className?: string\n disabled?: boolean\n modal?: boolean\n title?: string\n}) {\n const [open, setOpen] = useState(false)\n const blankLabel = emptyValueLabel || placeholder\n const realOptions = allowEmpty\n ? options.filter((option) => option.value !== \"\")\n : options\n const grouped = (groups ?? []).filter((group) => group.options.length > 0)\n const catalog = grouped.length\n ? grouped.flatMap((group) => group.options)\n : realOptions\n const listOptions = allOption ? [allOption, ...catalog] : catalog\n const emptyChoice: OptionComboboxOption = {\n value: \"\",\n label: blankLabel,\n icon: emptyValueIcon,\n }\n const selected = allowEmpty && value === \"\"\n ? emptyChoice\n : clearOption && value === \"\"\n ? clearOption\n : listOptions.find((option) => option.value === value)\n ?? (!value ? allOption : undefined)\n const selectedMany = multiple\n ? values.flatMap((id) => {\n const option = listOptions.find((item) => item.value === id)\n return option ? [option] : []\n })\n : []\n\n const chosen = (optionValue: string) =>\n multiple ? values.includes(optionValue) : value === optionValue\n\n const choose = (next: string) => {\n if (multiple) {\n onValuesChange?.(\n values.includes(next)\n ? values.filter((item) => item !== next)\n : [...values, next],\n )\n return\n }\n onChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover\n open={open}\n onOpenChange={disabled ? undefined : setOpen}\n modal={modal}\n >\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n disabled={disabled}\n title={title}\n className={cn(\n \"w-full justify-between font-normal\",\n multiple && \"h-auto py-2\",\n className,\n )}\n >\n {multiple ? (\n <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n {selectedMany.length ? (\n selectedMany.map((option) => (\n <OptionFace key={option.value} option={option} />\n ))\n ) : (\n <span className=\"truncate text-muted-foreground\">\n {placeholder}\n </span>\n )}\n </span>\n ) : selected ? (\n <OptionFace option={selected} />\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n {disabled ? null : (\n <PopoverContent\n align=\"start\"\n className=\"z-[60] w-[var(--radix-popover-trigger-width)] min-w-56 p-0\"\n >\n <Command filter={commandFilterAccentInsensitive}>\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>{emptyLabel}</CommandEmpty>\n {grouped.length ? (\n grouped.map((group) => (\n <CommandGroup\n key={group.heading ?? group.options[0]?.value}\n heading={group.heading}\n >\n {group.options.map((option) => (\n <CommandItem\n key={option.value || option.label}\n value={`${option.label} ${option.value}`}\n className={option.indented ? \"pl-6\" : undefined}\n onSelect={() => choose(option.value)}\n >\n <OptionFace option={option} />\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n chosen(option.value) ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n </CommandItem>\n ))}\n </CommandGroup>\n ))\n ) : (\n <CommandGroup heading={groupHeading}>\n {allowEmpty ? (\n <CommandItem\n value={`__empty__ ${blankLabel}`}\n onSelect={() => choose(\"\")}\n >\n <OptionGlyph icon={emptyValueIcon} />\n {blankLabel}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : null}\n {clearOption ? (\n <CommandItem\n value={`__clear__ ${clearOption.label}`}\n onSelect={() => choose(\"\")}\n >\n <OptionGlyph\n icon={clearOption.icon}\n className={clearOption.iconClassName}\n />\n {clearOption.label}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : clearLabel ? (\n <CommandItem\n value={`__clear__ ${clearLabel}`}\n onSelect={() => choose(\"\")}\n >\n {clearLabel}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : null}\n {listOptions.map((option) => (\n <CommandItem\n key={option.value || option.label}\n value={`${option.label} ${option.value}`}\n className={option.indented ? \"pl-6\" : undefined}\n onSelect={() => choose(option.value)}\n >\n <OptionFace option={option} />\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n chosen(option.value) ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n </CommandItem>\n ))}\n </CommandGroup>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n )\n}\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": ""
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -463,6 +463,16 @@
|
|
|
463
463
|
}
|
|
464
464
|
]
|
|
465
465
|
},
|
|
466
|
+
{
|
|
467
|
+
"name": "option-combobox",
|
|
468
|
+
"type": "registry:ui",
|
|
469
|
+
"files": [
|
|
470
|
+
{
|
|
471
|
+
"path": "json/option-combobox.json",
|
|
472
|
+
"type": "registry:file"
|
|
473
|
+
}
|
|
474
|
+
]
|
|
475
|
+
},
|
|
466
476
|
{
|
|
467
477
|
"name": "pagination",
|
|
468
478
|
"type": "registry:ui",
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentType } from "react"
|
|
4
|
+
import { IconCheck, IconSelector } from "@tabler/icons-react"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import {
|
|
7
|
+
Command,
|
|
8
|
+
CommandEmpty,
|
|
9
|
+
CommandGroup,
|
|
10
|
+
CommandInput,
|
|
11
|
+
CommandItem,
|
|
12
|
+
CommandList,
|
|
13
|
+
} from "@/components/ui/command"
|
|
14
|
+
import {
|
|
15
|
+
Popover,
|
|
16
|
+
PopoverContent,
|
|
17
|
+
PopoverTrigger,
|
|
18
|
+
} from "@/components/ui/popover"
|
|
19
|
+
import { cn, commandFilterAccentInsensitive } from "@/lib/utils"
|
|
20
|
+
|
|
21
|
+
export type OptionComboboxOption = {
|
|
22
|
+
value: string
|
|
23
|
+
label: string
|
|
24
|
+
icon?: ComponentType<{ className?: string; stroke?: number }>
|
|
25
|
+
iconClassName?: string
|
|
26
|
+
/** Pastilla de color, por ejemplo una etiqueta. */
|
|
27
|
+
color?: string
|
|
28
|
+
/** Sangría para una opción hija. */
|
|
29
|
+
indented?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function OptionGlyph({
|
|
33
|
+
icon: Icon,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
icon?: OptionComboboxOption["icon"]
|
|
37
|
+
className?: string
|
|
38
|
+
}) {
|
|
39
|
+
if (!Icon) return null
|
|
40
|
+
return <Icon className={cn("size-4 shrink-0", className)} stroke={1.75} />
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function OptionFace({ option }: { option: OptionComboboxOption }) {
|
|
44
|
+
if (option.color) {
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-xs font-medium"
|
|
48
|
+
style={{
|
|
49
|
+
color: option.color,
|
|
50
|
+
borderColor: option.color,
|
|
51
|
+
backgroundColor: `${option.color}1A`,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{option.label}
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
60
|
+
<OptionGlyph icon={option.icon} className={option.iconClassName} />
|
|
61
|
+
<span className="truncate">{option.label}</span>
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function OptionCombobox({
|
|
67
|
+
value = "",
|
|
68
|
+
options,
|
|
69
|
+
onChange,
|
|
70
|
+
placeholder = "Seleccionar…",
|
|
71
|
+
searchPlaceholder = "Buscar…",
|
|
72
|
+
emptyLabel = "No se encontraron resultados.",
|
|
73
|
+
groupHeading,
|
|
74
|
+
allowEmpty = false,
|
|
75
|
+
emptyValueLabel,
|
|
76
|
+
emptyValueIcon,
|
|
77
|
+
clearLabel,
|
|
78
|
+
clearOption,
|
|
79
|
+
allOption,
|
|
80
|
+
groups,
|
|
81
|
+
multiple = false,
|
|
82
|
+
values = [],
|
|
83
|
+
onValuesChange,
|
|
84
|
+
className,
|
|
85
|
+
disabled = false,
|
|
86
|
+
modal = false,
|
|
87
|
+
title,
|
|
88
|
+
}: {
|
|
89
|
+
value?: string
|
|
90
|
+
options: OptionComboboxOption[]
|
|
91
|
+
onChange?: (value: string) => void
|
|
92
|
+
/** Varias opciones a la vez. El menú no se cierra al marcar. */
|
|
93
|
+
multiple?: boolean
|
|
94
|
+
values?: string[]
|
|
95
|
+
onValuesChange?: (values: string[]) => void
|
|
96
|
+
placeholder?: string
|
|
97
|
+
searchPlaceholder?: string
|
|
98
|
+
emptyLabel?: string
|
|
99
|
+
groupHeading?: string
|
|
100
|
+
/** Si es true, la primera fila deja el valor vacío y no pertenece a `options`. */
|
|
101
|
+
allowEmpty?: boolean
|
|
102
|
+
/** Texto de esa fila vacía. Si no se indica, usa `placeholder`. */
|
|
103
|
+
emptyValueLabel?: string
|
|
104
|
+
emptyValueIcon?: OptionComboboxOption["icon"]
|
|
105
|
+
/** Opción para vaciar el valor. El botón sigue mostrando `placeholder`. */
|
|
106
|
+
clearLabel?: string
|
|
107
|
+
/** Opción para vaciar el valor, visible también en el botón. */
|
|
108
|
+
clearOption?: OptionComboboxOption
|
|
109
|
+
/** Opción extra al inicio. Si no hay valor, el botón la muestra. */
|
|
110
|
+
allOption?: OptionComboboxOption
|
|
111
|
+
/** Varios grupos, por ejemplo persona y empresa. */
|
|
112
|
+
groups?: { heading?: string; options: OptionComboboxOption[] }[]
|
|
113
|
+
className?: string
|
|
114
|
+
disabled?: boolean
|
|
115
|
+
modal?: boolean
|
|
116
|
+
title?: string
|
|
117
|
+
}) {
|
|
118
|
+
const [open, setOpen] = useState(false)
|
|
119
|
+
const blankLabel = emptyValueLabel || placeholder
|
|
120
|
+
const realOptions = allowEmpty
|
|
121
|
+
? options.filter((option) => option.value !== "")
|
|
122
|
+
: options
|
|
123
|
+
const grouped = (groups ?? []).filter((group) => group.options.length > 0)
|
|
124
|
+
const catalog = grouped.length
|
|
125
|
+
? grouped.flatMap((group) => group.options)
|
|
126
|
+
: realOptions
|
|
127
|
+
const listOptions = allOption ? [allOption, ...catalog] : catalog
|
|
128
|
+
const emptyChoice: OptionComboboxOption = {
|
|
129
|
+
value: "",
|
|
130
|
+
label: blankLabel,
|
|
131
|
+
icon: emptyValueIcon,
|
|
132
|
+
}
|
|
133
|
+
const selected = allowEmpty && value === ""
|
|
134
|
+
? emptyChoice
|
|
135
|
+
: clearOption && value === ""
|
|
136
|
+
? clearOption
|
|
137
|
+
: listOptions.find((option) => option.value === value)
|
|
138
|
+
?? (!value ? allOption : undefined)
|
|
139
|
+
const selectedMany = multiple
|
|
140
|
+
? values.flatMap((id) => {
|
|
141
|
+
const option = listOptions.find((item) => item.value === id)
|
|
142
|
+
return option ? [option] : []
|
|
143
|
+
})
|
|
144
|
+
: []
|
|
145
|
+
|
|
146
|
+
const chosen = (optionValue: string) =>
|
|
147
|
+
multiple ? values.includes(optionValue) : value === optionValue
|
|
148
|
+
|
|
149
|
+
const choose = (next: string) => {
|
|
150
|
+
if (multiple) {
|
|
151
|
+
onValuesChange?.(
|
|
152
|
+
values.includes(next)
|
|
153
|
+
? values.filter((item) => item !== next)
|
|
154
|
+
: [...values, next],
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
onChange?.(next)
|
|
159
|
+
setOpen(false)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Popover
|
|
164
|
+
open={open}
|
|
165
|
+
onOpenChange={disabled ? undefined : setOpen}
|
|
166
|
+
modal={modal}
|
|
167
|
+
>
|
|
168
|
+
<PopoverTrigger asChild>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="outline"
|
|
172
|
+
role="combobox"
|
|
173
|
+
aria-expanded={open}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
title={title}
|
|
176
|
+
className={cn(
|
|
177
|
+
"w-full justify-between font-normal",
|
|
178
|
+
multiple && "h-auto py-2",
|
|
179
|
+
className,
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{multiple ? (
|
|
183
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
184
|
+
{selectedMany.length ? (
|
|
185
|
+
selectedMany.map((option) => (
|
|
186
|
+
<OptionFace key={option.value} option={option} />
|
|
187
|
+
))
|
|
188
|
+
) : (
|
|
189
|
+
<span className="truncate text-muted-foreground">
|
|
190
|
+
{placeholder}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
) : selected ? (
|
|
195
|
+
<OptionFace option={selected} />
|
|
196
|
+
) : (
|
|
197
|
+
<span className="truncate text-muted-foreground">{placeholder}</span>
|
|
198
|
+
)}
|
|
199
|
+
<IconSelector className="size-4 shrink-0 opacity-50" />
|
|
200
|
+
</Button>
|
|
201
|
+
</PopoverTrigger>
|
|
202
|
+
{disabled ? null : (
|
|
203
|
+
<PopoverContent
|
|
204
|
+
align="start"
|
|
205
|
+
className="z-[60] w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
|
|
206
|
+
>
|
|
207
|
+
<Command filter={commandFilterAccentInsensitive}>
|
|
208
|
+
<CommandInput placeholder={searchPlaceholder} />
|
|
209
|
+
<CommandList>
|
|
210
|
+
<CommandEmpty>{emptyLabel}</CommandEmpty>
|
|
211
|
+
{grouped.length ? (
|
|
212
|
+
grouped.map((group) => (
|
|
213
|
+
<CommandGroup
|
|
214
|
+
key={group.heading ?? group.options[0]?.value}
|
|
215
|
+
heading={group.heading}
|
|
216
|
+
>
|
|
217
|
+
{group.options.map((option) => (
|
|
218
|
+
<CommandItem
|
|
219
|
+
key={option.value || option.label}
|
|
220
|
+
value={`${option.label} ${option.value}`}
|
|
221
|
+
className={option.indented ? "pl-6" : undefined}
|
|
222
|
+
onSelect={() => choose(option.value)}
|
|
223
|
+
>
|
|
224
|
+
<OptionFace option={option} />
|
|
225
|
+
<IconCheck
|
|
226
|
+
className={cn(
|
|
227
|
+
"ml-auto size-4",
|
|
228
|
+
chosen(option.value) ? "opacity-100" : "opacity-0",
|
|
229
|
+
)}
|
|
230
|
+
/>
|
|
231
|
+
</CommandItem>
|
|
232
|
+
))}
|
|
233
|
+
</CommandGroup>
|
|
234
|
+
))
|
|
235
|
+
) : (
|
|
236
|
+
<CommandGroup heading={groupHeading}>
|
|
237
|
+
{allowEmpty ? (
|
|
238
|
+
<CommandItem
|
|
239
|
+
value={`__empty__ ${blankLabel}`}
|
|
240
|
+
onSelect={() => choose("")}
|
|
241
|
+
>
|
|
242
|
+
<OptionGlyph icon={emptyValueIcon} />
|
|
243
|
+
{blankLabel}
|
|
244
|
+
<IconCheck
|
|
245
|
+
className={cn(
|
|
246
|
+
"ml-auto size-4",
|
|
247
|
+
value ? "opacity-0" : "opacity-100",
|
|
248
|
+
)}
|
|
249
|
+
/>
|
|
250
|
+
</CommandItem>
|
|
251
|
+
) : null}
|
|
252
|
+
{clearOption ? (
|
|
253
|
+
<CommandItem
|
|
254
|
+
value={`__clear__ ${clearOption.label}`}
|
|
255
|
+
onSelect={() => choose("")}
|
|
256
|
+
>
|
|
257
|
+
<OptionGlyph
|
|
258
|
+
icon={clearOption.icon}
|
|
259
|
+
className={clearOption.iconClassName}
|
|
260
|
+
/>
|
|
261
|
+
{clearOption.label}
|
|
262
|
+
<IconCheck
|
|
263
|
+
className={cn(
|
|
264
|
+
"ml-auto size-4",
|
|
265
|
+
value ? "opacity-0" : "opacity-100",
|
|
266
|
+
)}
|
|
267
|
+
/>
|
|
268
|
+
</CommandItem>
|
|
269
|
+
) : clearLabel ? (
|
|
270
|
+
<CommandItem
|
|
271
|
+
value={`__clear__ ${clearLabel}`}
|
|
272
|
+
onSelect={() => choose("")}
|
|
273
|
+
>
|
|
274
|
+
{clearLabel}
|
|
275
|
+
<IconCheck
|
|
276
|
+
className={cn(
|
|
277
|
+
"ml-auto size-4",
|
|
278
|
+
value ? "opacity-0" : "opacity-100",
|
|
279
|
+
)}
|
|
280
|
+
/>
|
|
281
|
+
</CommandItem>
|
|
282
|
+
) : null}
|
|
283
|
+
{listOptions.map((option) => (
|
|
284
|
+
<CommandItem
|
|
285
|
+
key={option.value || option.label}
|
|
286
|
+
value={`${option.label} ${option.value}`}
|
|
287
|
+
className={option.indented ? "pl-6" : undefined}
|
|
288
|
+
onSelect={() => choose(option.value)}
|
|
289
|
+
>
|
|
290
|
+
<OptionFace option={option} />
|
|
291
|
+
<IconCheck
|
|
292
|
+
className={cn(
|
|
293
|
+
"ml-auto size-4",
|
|
294
|
+
chosen(option.value) ? "opacity-100" : "opacity-0",
|
|
295
|
+
)}
|
|
296
|
+
/>
|
|
297
|
+
</CommandItem>
|
|
298
|
+
))}
|
|
299
|
+
</CommandGroup>
|
|
300
|
+
)}
|
|
301
|
+
</CommandList>
|
|
302
|
+
</Command>
|
|
303
|
+
</PopoverContent>
|
|
304
|
+
)}
|
|
305
|
+
</Popover>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentType } from "react"
|
|
4
|
+
import { IconCheck, IconSelector } from "@tabler/icons-react"
|
|
5
|
+
import { Button } from "./button"
|
|
6
|
+
import {
|
|
7
|
+
Command,
|
|
8
|
+
CommandEmpty,
|
|
9
|
+
CommandGroup,
|
|
10
|
+
CommandInput,
|
|
11
|
+
CommandItem,
|
|
12
|
+
CommandList,
|
|
13
|
+
} from "./command"
|
|
14
|
+
import {
|
|
15
|
+
Popover,
|
|
16
|
+
PopoverContent,
|
|
17
|
+
PopoverTrigger,
|
|
18
|
+
} from "./popover"
|
|
19
|
+
import { cn, commandFilterAccentInsensitive } from "../lib/utils"
|
|
20
|
+
|
|
21
|
+
export type OptionComboboxOption = {
|
|
22
|
+
value: string
|
|
23
|
+
label: string
|
|
24
|
+
icon?: ComponentType<{ className?: string; stroke?: number }>
|
|
25
|
+
iconClassName?: string
|
|
26
|
+
/** Pastilla de color, por ejemplo una etiqueta. */
|
|
27
|
+
color?: string
|
|
28
|
+
/** Sangría para una opción hija. */
|
|
29
|
+
indented?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function OptionGlyph({
|
|
33
|
+
icon: Icon,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
icon?: OptionComboboxOption["icon"]
|
|
37
|
+
className?: string
|
|
38
|
+
}) {
|
|
39
|
+
if (!Icon) return null
|
|
40
|
+
return <Icon className={cn("size-4 shrink-0", className)} stroke={1.75} />
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function OptionFace({ option }: { option: OptionComboboxOption }) {
|
|
44
|
+
if (option.color) {
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-xs font-medium"
|
|
48
|
+
style={{
|
|
49
|
+
color: option.color,
|
|
50
|
+
borderColor: option.color,
|
|
51
|
+
backgroundColor: `${option.color}1A`,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{option.label}
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
60
|
+
<OptionGlyph icon={option.icon} className={option.iconClassName} />
|
|
61
|
+
<span className="truncate">{option.label}</span>
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function OptionCombobox({
|
|
67
|
+
value = "",
|
|
68
|
+
options,
|
|
69
|
+
onChange,
|
|
70
|
+
placeholder = "Seleccionar…",
|
|
71
|
+
searchPlaceholder = "Buscar…",
|
|
72
|
+
emptyLabel = "No se encontraron resultados.",
|
|
73
|
+
groupHeading,
|
|
74
|
+
allowEmpty = false,
|
|
75
|
+
emptyValueLabel,
|
|
76
|
+
emptyValueIcon,
|
|
77
|
+
clearLabel,
|
|
78
|
+
clearOption,
|
|
79
|
+
allOption,
|
|
80
|
+
groups,
|
|
81
|
+
multiple = false,
|
|
82
|
+
values = [],
|
|
83
|
+
onValuesChange,
|
|
84
|
+
className,
|
|
85
|
+
disabled = false,
|
|
86
|
+
modal = false,
|
|
87
|
+
title,
|
|
88
|
+
}: {
|
|
89
|
+
value?: string
|
|
90
|
+
options: OptionComboboxOption[]
|
|
91
|
+
onChange?: (value: string) => void
|
|
92
|
+
/** Varias opciones a la vez. El menú no se cierra al marcar. */
|
|
93
|
+
multiple?: boolean
|
|
94
|
+
values?: string[]
|
|
95
|
+
onValuesChange?: (values: string[]) => void
|
|
96
|
+
placeholder?: string
|
|
97
|
+
searchPlaceholder?: string
|
|
98
|
+
emptyLabel?: string
|
|
99
|
+
groupHeading?: string
|
|
100
|
+
/** Si es true, la primera fila deja el valor vacío y no pertenece a `options`. */
|
|
101
|
+
allowEmpty?: boolean
|
|
102
|
+
/** Texto de esa fila vacía. Si no se indica, usa `placeholder`. */
|
|
103
|
+
emptyValueLabel?: string
|
|
104
|
+
emptyValueIcon?: OptionComboboxOption["icon"]
|
|
105
|
+
/** Opción para vaciar el valor. El botón sigue mostrando `placeholder`. */
|
|
106
|
+
clearLabel?: string
|
|
107
|
+
/** Opción para vaciar el valor, visible también en el botón. */
|
|
108
|
+
clearOption?: OptionComboboxOption
|
|
109
|
+
/** Opción extra al inicio. Si no hay valor, el botón la muestra. */
|
|
110
|
+
allOption?: OptionComboboxOption
|
|
111
|
+
/** Varios grupos, por ejemplo persona y empresa. */
|
|
112
|
+
groups?: { heading?: string; options: OptionComboboxOption[] }[]
|
|
113
|
+
className?: string
|
|
114
|
+
disabled?: boolean
|
|
115
|
+
modal?: boolean
|
|
116
|
+
title?: string
|
|
117
|
+
}) {
|
|
118
|
+
const [open, setOpen] = useState(false)
|
|
119
|
+
const blankLabel = emptyValueLabel || placeholder
|
|
120
|
+
const realOptions = allowEmpty
|
|
121
|
+
? options.filter((option) => option.value !== "")
|
|
122
|
+
: options
|
|
123
|
+
const grouped = (groups ?? []).filter((group) => group.options.length > 0)
|
|
124
|
+
const catalog = grouped.length
|
|
125
|
+
? grouped.flatMap((group) => group.options)
|
|
126
|
+
: realOptions
|
|
127
|
+
const listOptions = allOption ? [allOption, ...catalog] : catalog
|
|
128
|
+
const emptyChoice: OptionComboboxOption = {
|
|
129
|
+
value: "",
|
|
130
|
+
label: blankLabel,
|
|
131
|
+
icon: emptyValueIcon,
|
|
132
|
+
}
|
|
133
|
+
const selected = allowEmpty && value === ""
|
|
134
|
+
? emptyChoice
|
|
135
|
+
: clearOption && value === ""
|
|
136
|
+
? clearOption
|
|
137
|
+
: listOptions.find((option) => option.value === value)
|
|
138
|
+
?? (!value ? allOption : undefined)
|
|
139
|
+
const selectedMany = multiple
|
|
140
|
+
? values.flatMap((id) => {
|
|
141
|
+
const option = listOptions.find((item) => item.value === id)
|
|
142
|
+
return option ? [option] : []
|
|
143
|
+
})
|
|
144
|
+
: []
|
|
145
|
+
|
|
146
|
+
const chosen = (optionValue: string) =>
|
|
147
|
+
multiple ? values.includes(optionValue) : value === optionValue
|
|
148
|
+
|
|
149
|
+
const choose = (next: string) => {
|
|
150
|
+
if (multiple) {
|
|
151
|
+
onValuesChange?.(
|
|
152
|
+
values.includes(next)
|
|
153
|
+
? values.filter((item) => item !== next)
|
|
154
|
+
: [...values, next],
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
onChange?.(next)
|
|
159
|
+
setOpen(false)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Popover
|
|
164
|
+
open={open}
|
|
165
|
+
onOpenChange={disabled ? undefined : setOpen}
|
|
166
|
+
modal={modal}
|
|
167
|
+
>
|
|
168
|
+
<PopoverTrigger asChild>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="outline"
|
|
172
|
+
role="combobox"
|
|
173
|
+
aria-expanded={open}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
title={title}
|
|
176
|
+
className={cn(
|
|
177
|
+
"w-full justify-between font-normal",
|
|
178
|
+
multiple && "h-auto py-2",
|
|
179
|
+
className,
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{multiple ? (
|
|
183
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
184
|
+
{selectedMany.length ? (
|
|
185
|
+
selectedMany.map((option) => (
|
|
186
|
+
<OptionFace key={option.value} option={option} />
|
|
187
|
+
))
|
|
188
|
+
) : (
|
|
189
|
+
<span className="truncate text-muted-foreground">
|
|
190
|
+
{placeholder}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
) : selected ? (
|
|
195
|
+
<OptionFace option={selected} />
|
|
196
|
+
) : (
|
|
197
|
+
<span className="truncate text-muted-foreground">{placeholder}</span>
|
|
198
|
+
)}
|
|
199
|
+
<IconSelector className="size-4 shrink-0 opacity-50" />
|
|
200
|
+
</Button>
|
|
201
|
+
</PopoverTrigger>
|
|
202
|
+
{disabled ? null : (
|
|
203
|
+
<PopoverContent
|
|
204
|
+
align="start"
|
|
205
|
+
className="z-[60] w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
|
|
206
|
+
>
|
|
207
|
+
<Command filter={commandFilterAccentInsensitive}>
|
|
208
|
+
<CommandInput placeholder={searchPlaceholder} />
|
|
209
|
+
<CommandList>
|
|
210
|
+
<CommandEmpty>{emptyLabel}</CommandEmpty>
|
|
211
|
+
{grouped.length ? (
|
|
212
|
+
grouped.map((group) => (
|
|
213
|
+
<CommandGroup
|
|
214
|
+
key={group.heading ?? group.options[0]?.value}
|
|
215
|
+
heading={group.heading}
|
|
216
|
+
>
|
|
217
|
+
{group.options.map((option) => (
|
|
218
|
+
<CommandItem
|
|
219
|
+
key={option.value || option.label}
|
|
220
|
+
value={`${option.label} ${option.value}`}
|
|
221
|
+
className={option.indented ? "pl-6" : undefined}
|
|
222
|
+
onSelect={() => choose(option.value)}
|
|
223
|
+
>
|
|
224
|
+
<OptionFace option={option} />
|
|
225
|
+
<IconCheck
|
|
226
|
+
className={cn(
|
|
227
|
+
"ml-auto size-4",
|
|
228
|
+
chosen(option.value) ? "opacity-100" : "opacity-0",
|
|
229
|
+
)}
|
|
230
|
+
/>
|
|
231
|
+
</CommandItem>
|
|
232
|
+
))}
|
|
233
|
+
</CommandGroup>
|
|
234
|
+
))
|
|
235
|
+
) : (
|
|
236
|
+
<CommandGroup heading={groupHeading}>
|
|
237
|
+
{allowEmpty ? (
|
|
238
|
+
<CommandItem
|
|
239
|
+
value={`__empty__ ${blankLabel}`}
|
|
240
|
+
onSelect={() => choose("")}
|
|
241
|
+
>
|
|
242
|
+
<OptionGlyph icon={emptyValueIcon} />
|
|
243
|
+
{blankLabel}
|
|
244
|
+
<IconCheck
|
|
245
|
+
className={cn(
|
|
246
|
+
"ml-auto size-4",
|
|
247
|
+
value ? "opacity-0" : "opacity-100",
|
|
248
|
+
)}
|
|
249
|
+
/>
|
|
250
|
+
</CommandItem>
|
|
251
|
+
) : null}
|
|
252
|
+
{clearOption ? (
|
|
253
|
+
<CommandItem
|
|
254
|
+
value={`__clear__ ${clearOption.label}`}
|
|
255
|
+
onSelect={() => choose("")}
|
|
256
|
+
>
|
|
257
|
+
<OptionGlyph
|
|
258
|
+
icon={clearOption.icon}
|
|
259
|
+
className={clearOption.iconClassName}
|
|
260
|
+
/>
|
|
261
|
+
{clearOption.label}
|
|
262
|
+
<IconCheck
|
|
263
|
+
className={cn(
|
|
264
|
+
"ml-auto size-4",
|
|
265
|
+
value ? "opacity-0" : "opacity-100",
|
|
266
|
+
)}
|
|
267
|
+
/>
|
|
268
|
+
</CommandItem>
|
|
269
|
+
) : clearLabel ? (
|
|
270
|
+
<CommandItem
|
|
271
|
+
value={`__clear__ ${clearLabel}`}
|
|
272
|
+
onSelect={() => choose("")}
|
|
273
|
+
>
|
|
274
|
+
{clearLabel}
|
|
275
|
+
<IconCheck
|
|
276
|
+
className={cn(
|
|
277
|
+
"ml-auto size-4",
|
|
278
|
+
value ? "opacity-0" : "opacity-100",
|
|
279
|
+
)}
|
|
280
|
+
/>
|
|
281
|
+
</CommandItem>
|
|
282
|
+
) : null}
|
|
283
|
+
{listOptions.map((option) => (
|
|
284
|
+
<CommandItem
|
|
285
|
+
key={option.value || option.label}
|
|
286
|
+
value={`${option.label} ${option.value}`}
|
|
287
|
+
className={option.indented ? "pl-6" : undefined}
|
|
288
|
+
onSelect={() => choose(option.value)}
|
|
289
|
+
>
|
|
290
|
+
<OptionFace option={option} />
|
|
291
|
+
<IconCheck
|
|
292
|
+
className={cn(
|
|
293
|
+
"ml-auto size-4",
|
|
294
|
+
chosen(option.value) ? "opacity-100" : "opacity-0",
|
|
295
|
+
)}
|
|
296
|
+
/>
|
|
297
|
+
</CommandItem>
|
|
298
|
+
))}
|
|
299
|
+
</CommandGroup>
|
|
300
|
+
)}
|
|
301
|
+
</CommandList>
|
|
302
|
+
</Command>
|
|
303
|
+
</PopoverContent>
|
|
304
|
+
)}
|
|
305
|
+
</Popover>
|
|
306
|
+
)
|
|
307
|
+
}
|