@motor-hero/ui-kit 0.23.1 → 0.24.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.1",
3
+ "version": "0.24.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, 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,12 +28,28 @@ 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
30
43
  }
31
44
 
32
45
  /**
33
46
  * Select com busca (combobox) acessível e responsivo.
34
47
  *
35
- * - Desktop: Popover (modal) + cmdk — o dropdown acompanha a largura do gatilho.
48
+ * - Desktop: Popover não-modal + cmdk — o dropdown acompanha a largura do
49
+ * gatilho. Não-modal (com `pointer-events-auto` no conteúdo) de propósito:
50
+ * dentro de um Dialog, um Popover modal trava `body { pointer-events: none }`
51
+ * e prende o foco, então a busca não recebe clique/digitação (mesmo motivo do
52
+ * MultiCombobox).
36
53
  * - Mobile: Drawer (bottom sheet) + cmdk — evita os problemas de foco/clique de
37
54
  * Popover dentro de modal no touch, então a busca funciona no celular.
38
55
  *
@@ -51,10 +68,24 @@ export function Combobox({
51
68
  className,
52
69
  "aria-invalid": ariaInvalid,
53
70
  "aria-describedby": ariaDescribedby,
71
+ onSearchChange,
72
+ onLoadMore,
73
+ loading,
74
+ hasMore,
75
+ selectedOption,
54
76
  }: ComboboxProps) {
55
77
  const [open, setOpen] = React.useState(false)
56
78
  const isDesktop = useIsDesktop()
57
- const selected = options.find((option) => option.value === value)
79
+ const isServer = onSearchChange !== undefined
80
+ const selected =
81
+ selectedOption?.value === value
82
+ ? selectedOption
83
+ : options.find((option) => option.value === value)
84
+ const { listRef, sentinelRef } = useInfiniteScrollSentinel({
85
+ enabled: isServer && !!hasMore,
86
+ loading,
87
+ onLoadMore,
88
+ })
58
89
 
59
90
  const trigger = (
60
91
  <button
@@ -78,22 +109,33 @@ export function Combobox({
78
109
  )
79
110
 
80
111
  const command = (
81
- <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
112
+ <CommandPrimitive
113
+ shouldFilter={!isServer}
114
+ className="flex h-full w-full flex-col overflow-hidden"
115
+ >
82
116
  <div className="flex items-center border-b px-3">
83
117
  <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
84
118
  <CommandPrimitive.Input
85
119
  placeholder={searchPlaceholder}
120
+ onValueChange={isServer ? onSearchChange : undefined}
86
121
  className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
87
122
  />
88
123
  </div>
89
- <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
90
- <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
91
- {emptyMessage}
92
- </CommandPrimitive.Empty>
124
+ <CommandPrimitive.List
125
+ ref={listRef}
126
+ className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60"
127
+ >
128
+ {!isServer && (
129
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
130
+ {emptyMessage}
131
+ </CommandPrimitive.Empty>
132
+ )}
93
133
  {options.map((option) => (
94
134
  <CommandPrimitive.Item
95
135
  key={option.value}
96
- value={option.label}
136
+ // cliente: cmdk filtra pelo `value`, então usamos o label; servidor:
137
+ // o servidor já filtrou, então usamos o id (único, evita colisão de labels).
138
+ value={isServer ? option.value : option.label}
97
139
  disabled={option.disabled}
98
140
  onSelect={() => {
99
141
  onChange(option.value)
@@ -110,19 +152,32 @@ export function Combobox({
110
152
  <span className="truncate">{option.label}</span>
111
153
  </CommandPrimitive.Item>
112
154
  ))}
155
+ {isServer && !loading && options.length === 0 && (
156
+ <div className="py-6 text-center text-sm text-muted-foreground">
157
+ {emptyMessage}
158
+ </div>
159
+ )}
160
+ {isServer && loading && (
161
+ <div className="flex items-center justify-center py-6">
162
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
163
+ </div>
164
+ )}
165
+ {isServer && hasMore && (
166
+ <div ref={sentinelRef} aria-hidden className="h-1" />
167
+ )}
113
168
  </CommandPrimitive.List>
114
169
  </CommandPrimitive>
115
170
  )
116
171
 
117
172
  if (isDesktop) {
118
173
  return (
119
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
174
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
120
175
  <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
121
176
  <PopoverPrimitive.Portal>
122
177
  <PopoverPrimitive.Content
123
178
  align="start"
124
179
  sideOffset={4}
125
- className="z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
180
+ className="pointer-events-auto z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
126
181
  style={{ width: "var(--radix-popover-trigger-width)" }}
127
182
  >
128
183
  {command}
@@ -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
+ }