@motor-hero/ui-kit 0.23.2 → 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.2",
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,6 +28,18 @@ 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
  /**
@@ -55,10 +68,24 @@ export function Combobox({
55
68
  className,
56
69
  "aria-invalid": ariaInvalid,
57
70
  "aria-describedby": ariaDescribedby,
71
+ onSearchChange,
72
+ onLoadMore,
73
+ loading,
74
+ hasMore,
75
+ selectedOption,
58
76
  }: ComboboxProps) {
59
77
  const [open, setOpen] = React.useState(false)
60
78
  const isDesktop = useIsDesktop()
61
- 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
+ })
62
89
 
63
90
  const trigger = (
64
91
  <button
@@ -82,22 +109,33 @@ export function Combobox({
82
109
  )
83
110
 
84
111
  const command = (
85
- <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
+ >
86
116
  <div className="flex items-center border-b px-3">
87
117
  <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
88
118
  <CommandPrimitive.Input
89
119
  placeholder={searchPlaceholder}
120
+ onValueChange={isServer ? onSearchChange : undefined}
90
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"
91
122
  />
92
123
  </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>
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
+ )}
97
133
  {options.map((option) => (
98
134
  <CommandPrimitive.Item
99
135
  key={option.value}
100
- 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}
101
139
  disabled={option.disabled}
102
140
  onSelect={() => {
103
141
  onChange(option.value)
@@ -114,6 +152,19 @@ export function Combobox({
114
152
  <span className="truncate">{option.label}</span>
115
153
  </CommandPrimitive.Item>
116
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
+ )}
117
168
  </CommandPrimitive.List>
118
169
  </CommandPrimitive>
119
170
  )
@@ -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
+ }