@motor-hero/ui-kit 0.22.1 → 0.23.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.
@@ -0,0 +1,189 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover"
2
+ import { Command as CommandPrimitive } from "cmdk"
3
+ import { Check, ChevronsUpDown, Search, X } from "lucide-react"
4
+ import * as React from "react"
5
+ import { Drawer as DrawerPrimitive } from "vaul"
6
+ import { useIsDesktop } from "../hooks/use-is-desktop"
7
+ import { cn } from "../lib/utils"
8
+ import type { ComboboxOption } from "./combobox"
9
+
10
+ export interface MultiComboboxProps {
11
+ options: ComboboxOption[]
12
+ value: string[]
13
+ onChange: (value: string[]) => void
14
+ /** Texto do gatilho quando nada está selecionado */
15
+ placeholder?: string
16
+ /** Placeholder do campo de busca */
17
+ searchPlaceholder?: string
18
+ /** Mensagem quando a busca não retorna nada */
19
+ emptyMessage?: string
20
+ disabled?: boolean
21
+ id?: string
22
+ className?: string
23
+ "aria-invalid"?: boolean | "true" | "false"
24
+ "aria-describedby"?: string
25
+ }
26
+
27
+ /**
28
+ * Combobox de múltipla seleção com chips, acessível e responsivo.
29
+ *
30
+ * - Desktop: Popover (modal) + cmdk — o dropdown acompanha a largura do gatilho.
31
+ * - Mobile: Drawer (bottom sheet) + cmdk — evita os problemas de foco/clique de
32
+ * Popover dentro de modal no touch, então a busca funciona no celular.
33
+ *
34
+ * As opções selecionadas aparecem como chips dentro do gatilho; clicar numa
35
+ * opção da lista alterna a seleção (a lista permanece aberta) e o `×` de cada
36
+ * chip remove a opção. Aceita `aria-invalid`/`aria-describedby` (encaminhados
37
+ * pelo FormField em estado de erro) e fica com a borda vermelha quando inválido.
38
+ */
39
+ export function MultiCombobox({
40
+ options,
41
+ value,
42
+ onChange,
43
+ placeholder = "Selecione...",
44
+ searchPlaceholder = "Buscar...",
45
+ emptyMessage = "Nenhum resultado encontrado.",
46
+ disabled,
47
+ id,
48
+ className,
49
+ "aria-invalid": ariaInvalid,
50
+ "aria-describedby": ariaDescribedby,
51
+ }: MultiComboboxProps) {
52
+ const [open, setOpen] = React.useState(false)
53
+ const isDesktop = useIsDesktop()
54
+ const selected = options.filter((option) => value.includes(option.value))
55
+
56
+ const toggleValue = (optionValue: string) => {
57
+ onChange(
58
+ value.includes(optionValue)
59
+ ? value.filter((v) => v !== optionValue)
60
+ : [...value, optionValue],
61
+ )
62
+ }
63
+
64
+ const removeValue = (optionValue: string) => {
65
+ onChange(value.filter((v) => v !== optionValue))
66
+ }
67
+
68
+ const trigger = (
69
+ <div
70
+ id={id}
71
+ role="combobox"
72
+ tabIndex={disabled ? -1 : 0}
73
+ aria-expanded={open}
74
+ aria-invalid={ariaInvalid}
75
+ aria-describedby={ariaDescribedby}
76
+ aria-disabled={disabled}
77
+ onKeyDown={(event) => {
78
+ if (disabled) return
79
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
80
+ event.preventDefault()
81
+ setOpen(true)
82
+ }
83
+ }}
84
+ className={cn(
85
+ "flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
86
+ className,
87
+ )}
88
+ >
89
+ {selected.length === 0 ? (
90
+ <span className="text-muted-foreground">{placeholder}</span>
91
+ ) : (
92
+ selected.map((option) => (
93
+ <span
94
+ key={option.value}
95
+ className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground"
96
+ >
97
+ {option.label}
98
+ <button
99
+ type="button"
100
+ tabIndex={-1}
101
+ aria-label={`Remover ${option.label}`}
102
+ onClick={(event) => {
103
+ event.stopPropagation()
104
+ removeValue(option.value)
105
+ }}
106
+ onPointerDown={(event) => event.stopPropagation()}
107
+ className="rounded-sm opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
108
+ >
109
+ <X className="h-3 w-3" />
110
+ </button>
111
+ </span>
112
+ ))
113
+ )}
114
+ <ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
115
+ </div>
116
+ )
117
+
118
+ const command = (
119
+ <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
120
+ <div className="flex items-center border-b px-3">
121
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
122
+ <CommandPrimitive.Input
123
+ placeholder={searchPlaceholder}
124
+ className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
125
+ />
126
+ </div>
127
+ <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
128
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
129
+ {emptyMessage}
130
+ </CommandPrimitive.Empty>
131
+ {options.map((option) => (
132
+ <CommandPrimitive.Item
133
+ key={option.value}
134
+ value={option.label}
135
+ disabled={option.disabled}
136
+ onSelect={() => toggleValue(option.value)}
137
+ className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 sm:py-1.5"
138
+ >
139
+ <Check
140
+ className={cn(
141
+ "h-4 w-4 shrink-0",
142
+ value.includes(option.value) ? "opacity-100" : "opacity-0",
143
+ )}
144
+ />
145
+ <span className="truncate">{option.label}</span>
146
+ </CommandPrimitive.Item>
147
+ ))}
148
+ </CommandPrimitive.List>
149
+ </CommandPrimitive>
150
+ )
151
+
152
+ if (isDesktop) {
153
+ return (
154
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
155
+ <PopoverPrimitive.Trigger asChild disabled={disabled}>
156
+ {trigger}
157
+ </PopoverPrimitive.Trigger>
158
+ <PopoverPrimitive.Portal>
159
+ <PopoverPrimitive.Content
160
+ align="start"
161
+ sideOffset={4}
162
+ className="z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
163
+ style={{ width: "var(--radix-popover-trigger-width)" }}
164
+ >
165
+ {command}
166
+ </PopoverPrimitive.Content>
167
+ </PopoverPrimitive.Portal>
168
+ </PopoverPrimitive.Root>
169
+ )
170
+ }
171
+
172
+ return (
173
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
174
+ <DrawerPrimitive.Trigger asChild disabled={disabled}>
175
+ {trigger}
176
+ </DrawerPrimitive.Trigger>
177
+ <DrawerPrimitive.Portal>
178
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
179
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[85vh] flex-col rounded-t-2xl border bg-popover text-popover-foreground outline-none">
180
+ <DrawerPrimitive.Title className="sr-only">
181
+ {placeholder}
182
+ </DrawerPrimitive.Title>
183
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
184
+ {command}
185
+ </DrawerPrimitive.Content>
186
+ </DrawerPrimitive.Portal>
187
+ </DrawerPrimitive.Root>
188
+ )
189
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export { SearchInput } from "./components/search-input"
28
28
  export { PasswordInput } from "./components/password-input"
29
29
  export { Combobox } from "./components/combobox"
30
30
  export type { ComboboxOption, ComboboxProps } from "./components/combobox"
31
+ export { MultiCombobox } from "./components/multi-combobox"
32
+ export type { MultiComboboxProps } from "./components/multi-combobox"
31
33
  export { ColorPicker } from "./components/color-picker"
32
34
  export type { ColorPickerProps } from "./components/color-picker"
33
35
  export { StatCard } from "./components/stat-card"