@motor-hero/ui-kit 0.23.2 → 0.25.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-hero/ui-kit",
3
- "version": "0.23.2",
3
+ "version": "0.25.0",
4
4
  "description": "Componentes React reutilizáveis para o ecossistema MotorHero — shadcn/ui + Tailwind CSS v4",
5
5
  "author": "MotorHero",
6
6
  "license": "UNLICENSED",
@@ -1,8 +1,9 @@
1
1
  import * as PopoverPrimitive from "@radix-ui/react-popover"
2
2
  import { Command as CommandPrimitive } from "cmdk"
3
- import { Check, ChevronsUpDown, 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
+ import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel"
6
7
  import { useIsDesktop } from "../hooks/use-is-desktop"
7
8
  import { cn } from "../lib/utils"
8
9
 
@@ -27,6 +28,24 @@ export interface ComboboxProps {
27
28
  className?: string
28
29
  "aria-invalid"?: boolean | "true" | "false"
29
30
  "aria-describedby"?: string
31
+ /** Modo servidor: chamado a cada tecla com o texto bruto da busca. Sua
32
+ * presença ativa o modo servidor (filtro do cmdk desligado). */
33
+ onSearchChange?: (search: string) => void
34
+ /** Modo servidor: chamado ao rolar até o fim da lista. */
35
+ onLoadMore?: () => void
36
+ /** Modo servidor: mostra uma linha de carregamento no fim da lista. */
37
+ loading?: boolean
38
+ /** Modo servidor: habilita o disparo de `onLoadMore`. */
39
+ hasMore?: boolean
40
+ /** Modo servidor: rótulo do valor já selecionado que pode não estar na
41
+ * página carregada (ex.: formulário de edição). */
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
30
49
  }
31
50
 
32
51
  /**
@@ -55,10 +74,51 @@ export function Combobox({
55
74
  className,
56
75
  "aria-invalid": ariaInvalid,
57
76
  "aria-describedby": ariaDescribedby,
77
+ onSearchChange,
78
+ onLoadMore,
79
+ loading,
80
+ hasMore,
81
+ selectedOption,
82
+ creatable,
83
+ formatCreateLabel,
58
84
  }: ComboboxProps) {
59
85
  const [open, setOpen] = React.useState(false)
86
+ const [search, setSearch] = React.useState("")
60
87
  const isDesktop = useIsDesktop()
61
- const selected = options.find((option) => option.value === value)
88
+ const isServer = onSearchChange !== undefined
89
+ const selected =
90
+ selectedOption?.value === value
91
+ ? selectedOption
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
+ )
117
+ const { listRef, sentinelRef } = useInfiniteScrollSentinel({
118
+ enabled: isServer && !!hasMore,
119
+ loading,
120
+ onLoadMore,
121
+ })
62
122
 
63
123
  const trigger = (
64
124
  <button
@@ -82,27 +142,52 @@ export function Combobox({
82
142
  )
83
143
 
84
144
  const command = (
85
- <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
145
+ <CommandPrimitive
146
+ shouldFilter={!isServer}
147
+ className="flex h-full w-full flex-col overflow-hidden"
148
+ >
86
149
  <div className="flex items-center border-b px-3">
87
150
  <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
88
151
  <CommandPrimitive.Input
89
152
  placeholder={searchPlaceholder}
153
+ onValueChange={
154
+ isServer || creatable ? handleSearchChange : undefined
155
+ }
90
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"
91
157
  />
92
158
  </div>
93
- <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
94
- <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
95
- {emptyMessage}
96
- </CommandPrimitive.Empty>
159
+ <CommandPrimitive.List
160
+ ref={listRef}
161
+ className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60"
162
+ >
163
+ {!isServer && !showCreate && (
164
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
165
+ {emptyMessage}
166
+ </CommandPrimitive.Empty>
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
+ )}
97
183
  {options.map((option) => (
98
184
  <CommandPrimitive.Item
99
185
  key={option.value}
100
- value={option.label}
186
+ // cliente: cmdk filtra pelo `value`, então usamos o label; servidor:
187
+ // o servidor já filtrou, então usamos o id (único, evita colisão de labels).
188
+ value={isServer ? option.value : option.label}
101
189
  disabled={option.disabled}
102
- onSelect={() => {
103
- onChange(option.value)
104
- setOpen(false)
105
- }}
190
+ onSelect={() => handleSelect(option.value)}
106
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"
107
192
  >
108
193
  <Check
@@ -114,13 +199,26 @@ export function Combobox({
114
199
  <span className="truncate">{option.label}</span>
115
200
  </CommandPrimitive.Item>
116
201
  ))}
202
+ {isServer && !loading && options.length === 0 && (
203
+ <div className="py-6 text-center text-sm text-muted-foreground">
204
+ {emptyMessage}
205
+ </div>
206
+ )}
207
+ {isServer && loading && (
208
+ <div className="flex items-center justify-center py-6">
209
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
210
+ </div>
211
+ )}
212
+ {isServer && hasMore && (
213
+ <div ref={sentinelRef} aria-hidden className="h-1" />
214
+ )}
117
215
  </CommandPrimitive.List>
118
216
  </CommandPrimitive>
119
217
  )
120
218
 
121
219
  if (isDesktop) {
122
220
  return (
123
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
221
+ <PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange}>
124
222
  <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
125
223
  <PopoverPrimitive.Portal>
126
224
  <PopoverPrimitive.Content
@@ -137,7 +235,7 @@ export function Combobox({
137
235
  }
138
236
 
139
237
  return (
140
- <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
238
+ <DrawerPrimitive.Root open={open} onOpenChange={handleOpenChange}>
141
239
  <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
142
240
  <DrawerPrimitive.Portal>
143
241
  <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
@@ -1,8 +1,9 @@
1
1
  import * as PopoverPrimitive from "@radix-ui/react-popover"
2
2
  import { Command as CommandPrimitive } from "cmdk"
3
- import { Check, ChevronsUpDown, Search, X } from "lucide-react"
3
+ import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react"
4
4
  import * as React from "react"
5
5
  import { Drawer as DrawerPrimitive } from "vaul"
6
+ import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel"
6
7
  import { useIsDesktop } from "../hooks/use-is-desktop"
7
8
  import { cn } from "../lib/utils"
8
9
  import type { ComboboxOption } from "./combobox"
@@ -22,6 +23,18 @@ export interface MultiComboboxProps {
22
23
  className?: string
23
24
  "aria-invalid"?: boolean | "true" | "false"
24
25
  "aria-describedby"?: string
26
+ /** Modo servidor: chamado a cada tecla com o texto bruto da busca. Sua
27
+ * presença ativa o modo servidor (filtro do cmdk desligado). */
28
+ onSearchChange?: (search: string) => void
29
+ /** Modo servidor: chamado ao rolar até o fim da lista. */
30
+ onLoadMore?: () => void
31
+ /** Modo servidor: mostra uma linha de carregamento no fim da lista. */
32
+ loading?: boolean
33
+ /** Modo servidor: habilita o disparo de `onLoadMore`. */
34
+ hasMore?: boolean
35
+ /** Modo servidor: rótulos dos valores já selecionados que podem não estar
36
+ * na página carregada (chips do formulário de edição). */
37
+ selectedOptions?: ComboboxOption[]
25
38
  }
26
39
 
27
40
  /**
@@ -51,10 +64,28 @@ export function MultiCombobox({
51
64
  className,
52
65
  "aria-invalid": ariaInvalid,
53
66
  "aria-describedby": ariaDescribedby,
67
+ onSearchChange,
68
+ onLoadMore,
69
+ loading,
70
+ hasMore,
71
+ selectedOptions,
54
72
  }: MultiComboboxProps) {
55
73
  const [open, setOpen] = React.useState(false)
56
74
  const isDesktop = useIsDesktop()
57
- const selected = options.filter((option) => value.includes(option.value))
75
+ const isServer = onSearchChange !== undefined
76
+ // Chips a partir de selectedOptions (rótulos salvos) + página atual, dedup
77
+ // por value (primeira ocorrência vence → o rótulo salvo prevalece).
78
+ const seen = new Set<string>()
79
+ const selected = [...(selectedOptions ?? []), ...options].filter((option) => {
80
+ if (!value.includes(option.value) || seen.has(option.value)) return false
81
+ seen.add(option.value)
82
+ return true
83
+ })
84
+ const { listRef, sentinelRef } = useInfiniteScrollSentinel({
85
+ enabled: isServer && !!hasMore,
86
+ loading,
87
+ onLoadMore,
88
+ })
58
89
 
59
90
  const toggleValue = (optionValue: string) => {
60
91
  onChange(
@@ -119,22 +150,31 @@ export function MultiCombobox({
119
150
  )
120
151
 
121
152
  const command = (
122
- <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
153
+ <CommandPrimitive
154
+ shouldFilter={!isServer}
155
+ className="flex h-full w-full flex-col overflow-hidden"
156
+ >
123
157
  <div className="flex items-center border-b px-3">
124
158
  <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
125
159
  <CommandPrimitive.Input
126
160
  placeholder={searchPlaceholder}
161
+ onValueChange={isServer ? onSearchChange : undefined}
127
162
  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
163
  />
129
164
  </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>
165
+ <CommandPrimitive.List
166
+ ref={listRef}
167
+ className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60"
168
+ >
169
+ {!isServer && (
170
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
171
+ {emptyMessage}
172
+ </CommandPrimitive.Empty>
173
+ )}
134
174
  {options.map((option) => (
135
175
  <CommandPrimitive.Item
136
176
  key={option.value}
137
- value={option.label}
177
+ value={isServer ? option.value : option.label}
138
178
  disabled={option.disabled}
139
179
  onSelect={() => toggleValue(option.value)}
140
180
  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"
@@ -148,6 +188,19 @@ export function MultiCombobox({
148
188
  <span className="truncate">{option.label}</span>
149
189
  </CommandPrimitive.Item>
150
190
  ))}
191
+ {isServer && !loading && options.length === 0 && (
192
+ <div className="py-6 text-center text-sm text-muted-foreground">
193
+ {emptyMessage}
194
+ </div>
195
+ )}
196
+ {isServer && loading && (
197
+ <div className="flex items-center justify-center py-6">
198
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
199
+ </div>
200
+ )}
201
+ {isServer && hasMore && (
202
+ <div ref={sentinelRef} aria-hidden className="h-1" />
203
+ )}
151
204
  </CommandPrimitive.List>
152
205
  </CommandPrimitive>
153
206
  )
@@ -0,0 +1,47 @@
1
+ import * as React from "react"
2
+
3
+ /**
4
+ * Dispara `onLoadMore` quando o `sentinelRef` entra na viewport do `listRef`
5
+ * (raiz = a lista rolável, não a janela — a lista rola por dentro do Popover/
6
+ * Drawer). Usa refs "latest" para `loading`/`onLoadMore` e evita reassinar o
7
+ * observer a cada render.
8
+ */
9
+ export function useInfiniteScrollSentinel({
10
+ enabled,
11
+ loading,
12
+ onLoadMore,
13
+ }: {
14
+ enabled: boolean
15
+ loading?: boolean
16
+ onLoadMore?: () => void
17
+ }) {
18
+ const listRef = React.useRef<HTMLDivElement | null>(null)
19
+ const sentinelRef = React.useRef<HTMLDivElement | null>(null)
20
+ const loadingRef = React.useRef(loading)
21
+ const onLoadMoreRef = React.useRef(onLoadMore)
22
+
23
+ React.useEffect(() => {
24
+ loadingRef.current = loading
25
+ onLoadMoreRef.current = onLoadMore
26
+ })
27
+
28
+ React.useEffect(() => {
29
+ if (!enabled) return
30
+ const root = listRef.current
31
+ const sentinel = sentinelRef.current
32
+ if (!root || !sentinel) return
33
+
34
+ const observer = new IntersectionObserver(
35
+ (entries) => {
36
+ if (entries[0]?.isIntersecting && !loadingRef.current) {
37
+ onLoadMoreRef.current?.()
38
+ }
39
+ },
40
+ { root, rootMargin: "0px 0px 100px 0px" },
41
+ )
42
+ observer.observe(sentinel)
43
+ return () => observer.disconnect()
44
+ }, [enabled])
45
+
46
+ return { listRef, sentinelRef }
47
+ }