@motor-hero/ui-kit 0.24.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-hero/ui-kit",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Componentes React reutilizáveis para o ecossistema MotorHero — shadcn/ui + Tailwind CSS v4",
5
5
  "author": "MotorHero",
6
6
  "license": "UNLICENSED",
@@ -39,7 +39,7 @@ export function ColorPicker({
39
39
  const color = value || ""
40
40
 
41
41
  return (
42
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
42
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
43
43
  <PopoverPrimitive.Trigger asChild>
44
44
  <button
45
45
  type="button"
@@ -63,9 +63,9 @@ export function ColorPicker({
63
63
  color
64
64
  ? { backgroundColor: color }
65
65
  : {
66
- backgroundImage:
67
- "linear-gradient(45deg,var(--color-muted) 25%,transparent 25%,transparent 75%,var(--color-muted) 75%),linear-gradient(45deg,var(--color-muted) 25%,transparent 25%,transparent 75%,var(--color-muted) 75%)",
68
- }
66
+ backgroundImage:
67
+ "linear-gradient(45deg,var(--color-muted) 25%,transparent 25%,transparent 75%,var(--color-muted) 75%),linear-gradient(45deg,var(--color-muted) 25%,transparent 25%,transparent 75%,var(--color-muted) 75%)",
68
+ }
69
69
  }
70
70
  />
71
71
  <span className={cn("truncate", !color && "text-muted-foreground")}>
@@ -1,6 +1,6 @@
1
1
  import * as PopoverPrimitive from "@radix-ui/react-popover"
2
2
  import { Command as CommandPrimitive } from "cmdk"
3
- import { Check, ChevronsUpDown, Loader2, Search } from "lucide-react"
3
+ import { Check, ChevronsUpDown, Loader2, Plus, Search } from "lucide-react"
4
4
  import * as React from "react"
5
5
  import { Drawer as DrawerPrimitive } from "vaul"
6
6
  import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel"
@@ -40,6 +40,12 @@ export interface ComboboxProps {
40
40
  /** Modo servidor: rótulo do valor já selecionado que pode não estar na
41
41
  * página carregada (ex.: formulário de edição). */
42
42
  selectedOption?: ComboboxOption
43
+ /** Permite confirmar o texto digitado como valor, mesmo fora da lista
44
+ * (ex.: unidade de medida com texto livre). Mostra uma linha "criar" no
45
+ * topo e exibe o valor personalizado no gatilho. */
46
+ creatable?: boolean
47
+ /** Rótulo da linha de criação. Padrão: `Usar "<texto>"`. */
48
+ formatCreateLabel?: (search: string) => string
43
49
  }
44
50
 
45
51
  /**
@@ -73,14 +79,41 @@ export function Combobox({
73
79
  loading,
74
80
  hasMore,
75
81
  selectedOption,
82
+ creatable,
83
+ formatCreateLabel,
76
84
  }: ComboboxProps) {
77
85
  const [open, setOpen] = React.useState(false)
86
+ const [search, setSearch] = React.useState("")
78
87
  const isDesktop = useIsDesktop()
79
88
  const isServer = onSearchChange !== undefined
80
89
  const selected =
81
90
  selectedOption?.value === value
82
91
  ? selectedOption
83
- : options.find((option) => option.value === value)
92
+ : (options.find((option) => option.value === value) ??
93
+ (creatable && value ? { value, label: value } : undefined))
94
+
95
+ const handleOpenChange = (next: boolean) => {
96
+ setOpen(next)
97
+ if (!next) setSearch("")
98
+ }
99
+
100
+ const handleSelect = (next: string) => {
101
+ onChange(next)
102
+ handleOpenChange(false)
103
+ }
104
+
105
+ const handleSearchChange = (next: string) => {
106
+ setSearch(next)
107
+ onSearchChange?.(next)
108
+ }
109
+
110
+ const trimmedSearch = search.trim()
111
+ const showCreate =
112
+ !!creatable &&
113
+ trimmedSearch.length > 0 &&
114
+ !options.some(
115
+ (option) => option.value.toLowerCase() === trimmedSearch.toLowerCase(),
116
+ )
84
117
  const { listRef, sentinelRef } = useInfiniteScrollSentinel({
85
118
  enabled: isServer && !!hasMore,
86
119
  loading,
@@ -117,7 +150,9 @@ export function Combobox({
117
150
  <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
118
151
  <CommandPrimitive.Input
119
152
  placeholder={searchPlaceholder}
120
- onValueChange={isServer ? onSearchChange : undefined}
153
+ onValueChange={
154
+ isServer || creatable ? handleSearchChange : undefined
155
+ }
121
156
  className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
122
157
  />
123
158
  </div>
@@ -125,11 +160,26 @@ export function Combobox({
125
160
  ref={listRef}
126
161
  className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60"
127
162
  >
128
- {!isServer && (
163
+ {!isServer && !showCreate && (
129
164
  <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
130
165
  {emptyMessage}
131
166
  </CommandPrimitive.Empty>
132
167
  )}
168
+ {showCreate && (
169
+ <CommandPrimitive.Item
170
+ // cmdk pontua pelo `value`; igualar à busca mantém a linha sempre no topo.
171
+ value={trimmedSearch}
172
+ onSelect={() => handleSelect(trimmedSearch)}
173
+ 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 sm:py-1.5"
174
+ >
175
+ <Plus className="h-4 w-4 shrink-0" />
176
+ <span className="truncate">
177
+ {(formatCreateLabel ?? ((text) => `Usar "${text}"`))(
178
+ trimmedSearch,
179
+ )}
180
+ </span>
181
+ </CommandPrimitive.Item>
182
+ )}
133
183
  {options.map((option) => (
134
184
  <CommandPrimitive.Item
135
185
  key={option.value}
@@ -137,10 +187,7 @@ export function Combobox({
137
187
  // o servidor já filtrou, então usamos o id (único, evita colisão de labels).
138
188
  value={isServer ? option.value : option.label}
139
189
  disabled={option.disabled}
140
- onSelect={() => {
141
- onChange(option.value)
142
- setOpen(false)
143
- }}
190
+ onSelect={() => handleSelect(option.value)}
144
191
  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"
145
192
  >
146
193
  <Check
@@ -171,7 +218,7 @@ export function Combobox({
171
218
 
172
219
  if (isDesktop) {
173
220
  return (
174
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
221
+ <PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange}>
175
222
  <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
176
223
  <PopoverPrimitive.Portal>
177
224
  <PopoverPrimitive.Content
@@ -188,7 +235,7 @@ export function Combobox({
188
235
  }
189
236
 
190
237
  return (
191
- <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
238
+ <DrawerPrimitive.Root open={open} onOpenChange={handleOpenChange}>
192
239
  <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
193
240
  <DrawerPrimitive.Portal>
194
241
  <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />