@motor-hero/ui-kit 0.22.1 → 0.23.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.
@@ -0,0 +1,192 @@
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 não-modal + cmdk — o dropdown acompanha a largura do gatilho.
31
+ * Não-modal de propósito: dentro de um Dialog, um Popover modal trava
32
+ * `body { pointer-events: none }` e marca o resto do Dialog como aria-hidden,
33
+ * o que deixava a página/Dialog presos ao fechar com o picker aberto.
34
+ * - Mobile: Drawer (bottom sheet) + cmdk — evita os problemas de foco/clique de
35
+ * Popover dentro de modal no touch, então a busca funciona no celular.
36
+ *
37
+ * As opções selecionadas aparecem como chips dentro do gatilho; clicar numa
38
+ * opção da lista alterna a seleção (a lista permanece aberta) e o `×` de cada
39
+ * chip remove a opção. Aceita `aria-invalid`/`aria-describedby` (encaminhados
40
+ * pelo FormField em estado de erro) e fica com a borda vermelha quando inválido.
41
+ */
42
+ export function MultiCombobox({
43
+ options,
44
+ value,
45
+ onChange,
46
+ placeholder = "Selecione...",
47
+ searchPlaceholder = "Buscar...",
48
+ emptyMessage = "Nenhum resultado encontrado.",
49
+ disabled,
50
+ id,
51
+ className,
52
+ "aria-invalid": ariaInvalid,
53
+ "aria-describedby": ariaDescribedby,
54
+ }: MultiComboboxProps) {
55
+ const [open, setOpen] = React.useState(false)
56
+ const isDesktop = useIsDesktop()
57
+ const selected = options.filter((option) => value.includes(option.value))
58
+
59
+ const toggleValue = (optionValue: string) => {
60
+ onChange(
61
+ value.includes(optionValue)
62
+ ? value.filter((v) => v !== optionValue)
63
+ : [...value, optionValue],
64
+ )
65
+ }
66
+
67
+ const removeValue = (optionValue: string) => {
68
+ onChange(value.filter((v) => v !== optionValue))
69
+ }
70
+
71
+ const trigger = (
72
+ <div
73
+ id={id}
74
+ role="combobox"
75
+ tabIndex={disabled ? -1 : 0}
76
+ aria-expanded={open}
77
+ aria-invalid={ariaInvalid}
78
+ aria-describedby={ariaDescribedby}
79
+ aria-disabled={disabled}
80
+ onKeyDown={(event) => {
81
+ if (disabled) return
82
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
83
+ event.preventDefault()
84
+ setOpen(true)
85
+ }
86
+ }}
87
+ className={cn(
88
+ "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",
89
+ className,
90
+ )}
91
+ >
92
+ {selected.length === 0 ? (
93
+ <span className="text-muted-foreground">{placeholder}</span>
94
+ ) : (
95
+ selected.map((option) => (
96
+ <span
97
+ key={option.value}
98
+ className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground"
99
+ >
100
+ {option.label}
101
+ <button
102
+ type="button"
103
+ tabIndex={-1}
104
+ aria-label={`Remover ${option.label}`}
105
+ onClick={(event) => {
106
+ event.stopPropagation()
107
+ removeValue(option.value)
108
+ }}
109
+ onPointerDown={(event) => event.stopPropagation()}
110
+ className="rounded-sm opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
111
+ >
112
+ <X className="h-3 w-3" />
113
+ </button>
114
+ </span>
115
+ ))
116
+ )}
117
+ <ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
118
+ </div>
119
+ )
120
+
121
+ const command = (
122
+ <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
123
+ <div className="flex items-center border-b px-3">
124
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
125
+ <CommandPrimitive.Input
126
+ placeholder={searchPlaceholder}
127
+ className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
128
+ />
129
+ </div>
130
+ <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
131
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
132
+ {emptyMessage}
133
+ </CommandPrimitive.Empty>
134
+ {options.map((option) => (
135
+ <CommandPrimitive.Item
136
+ key={option.value}
137
+ value={option.label}
138
+ disabled={option.disabled}
139
+ onSelect={() => toggleValue(option.value)}
140
+ 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"
141
+ >
142
+ <Check
143
+ className={cn(
144
+ "h-4 w-4 shrink-0",
145
+ value.includes(option.value) ? "opacity-100" : "opacity-0",
146
+ )}
147
+ />
148
+ <span className="truncate">{option.label}</span>
149
+ </CommandPrimitive.Item>
150
+ ))}
151
+ </CommandPrimitive.List>
152
+ </CommandPrimitive>
153
+ )
154
+
155
+ if (isDesktop) {
156
+ return (
157
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
158
+ <PopoverPrimitive.Trigger asChild disabled={disabled}>
159
+ {trigger}
160
+ </PopoverPrimitive.Trigger>
161
+ <PopoverPrimitive.Portal>
162
+ <PopoverPrimitive.Content
163
+ align="start"
164
+ sideOffset={4}
165
+ className="pointer-events-auto z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
166
+ style={{ width: "var(--radix-popover-trigger-width)" }}
167
+ >
168
+ {command}
169
+ </PopoverPrimitive.Content>
170
+ </PopoverPrimitive.Portal>
171
+ </PopoverPrimitive.Root>
172
+ )
173
+ }
174
+
175
+ return (
176
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
177
+ <DrawerPrimitive.Trigger asChild disabled={disabled}>
178
+ {trigger}
179
+ </DrawerPrimitive.Trigger>
180
+ <DrawerPrimitive.Portal>
181
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
182
+ <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">
183
+ <DrawerPrimitive.Title className="sr-only">
184
+ {placeholder}
185
+ </DrawerPrimitive.Title>
186
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
187
+ {command}
188
+ </DrawerPrimitive.Content>
189
+ </DrawerPrimitive.Portal>
190
+ </DrawerPrimitive.Root>
191
+ )
192
+ }
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"