@motor-hero/ui-kit 0.51.0 → 0.52.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.51.0",
3
+ "version": "0.52.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,5 +1,5 @@
1
1
  import * as PopoverPrimitive from "@radix-ui/react-popover";
2
- import { Command as CommandPrimitive } from "cmdk";
2
+ import { Command as CommandPrimitive, defaultFilter } from "cmdk";
3
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";
@@ -163,7 +163,10 @@ export function Combobox({
163
163
  className,
164
164
  )}
165
165
  >
166
- <span className={cn("truncate", !selected && "text-muted-foreground")}>
166
+ <span
167
+ title={selected?.description}
168
+ className={cn("truncate", !selected && "text-muted-foreground")}
169
+ >
167
170
  {selected ? selected.label : placeholder}
168
171
  </span>
169
172
  <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
@@ -173,6 +176,7 @@ export function Combobox({
173
176
  const command = (
174
177
  <CommandPrimitive
175
178
  shouldFilter={!isServer}
179
+ filter={filterByKeywords}
176
180
  className="flex h-full w-full flex-col overflow-hidden"
177
181
  >
178
182
  <div className="flex items-center border-b px-3">
@@ -194,8 +198,10 @@ export function Combobox({
194
198
  )}
195
199
  {showCreate && (
196
200
  <CommandPrimitive.Item
197
- // cmdk pontua pelo `value`; igualar à busca mantém a linha sempre no topo.
201
+ // A busca como `value`, não uma chave fixa: o cmdk só relê as
202
+ // keywords quando o `value` muda. Keyword igual à busca = sempre no topo.
198
203
  value={trimmedSearch}
204
+ keywords={[trimmedSearch]}
199
205
  onSelect={() => handleSelect(trimmedSearch)}
200
206
  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"
201
207
  >
@@ -210,10 +216,8 @@ export function Combobox({
210
216
  {options.map((option) => (
211
217
  <CommandPrimitive.Item
212
218
  key={option.value}
213
- // cliente: cmdk filtra pelo `value`, então usamos o label; servidor:
214
- // o servidor já filtrou, então usamos o id (único, evita colisão de labels).
215
- value={isServer ? option.value : option.label}
216
- keywords={option.description ? [option.description] : undefined}
219
+ value={option.value}
220
+ keywords={optionKeywords(option)}
217
221
  disabled={option.disabled}
218
222
  onSelect={() => handleSelect(option.value)}
219
223
  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"
@@ -280,6 +284,18 @@ export function Combobox({
280
284
  );
281
285
  }
282
286
 
287
+ // O `value` do item é o id, único mesmo entre homônimos — rótulo igual como
288
+ // `value` fazia o cmdk tratar duas opções como uma (hover, teclado, seleção).
289
+ // A busca pontua só pelas keywords (rótulo + descrição), nunca pelo id.
290
+ export const filterByKeywords = (
291
+ _value: string,
292
+ search: string,
293
+ keywords: string[] = [],
294
+ ) => defaultFilter(keywords.join(" "), search);
295
+
296
+ export const optionKeywords = (option: ComboboxOption) =>
297
+ option.description ? [option.label, option.description] : [option.label];
298
+
283
299
  export function OptionText({ option }: { option: ComboboxOption }) {
284
300
  if (!option.description)
285
301
  return <span className="truncate">{option.label}</span>;
@@ -6,7 +6,12 @@ import { Drawer as DrawerPrimitive } from "vaul"
6
6
  import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel"
7
7
  import { useIsDesktop } from "../hooks/use-is-desktop"
8
8
  import { cn } from "../lib/utils"
9
- import { type ComboboxOption, OptionText } from "./combobox"
9
+ import {
10
+ type ComboboxOption,
11
+ filterByKeywords,
12
+ OptionText,
13
+ optionKeywords,
14
+ } from "./combobox"
10
15
 
11
16
  export interface MultiComboboxProps {
12
17
  options: ComboboxOption[]
@@ -182,6 +187,7 @@ export function MultiCombobox({
182
187
  const command = (
183
188
  <CommandPrimitive
184
189
  shouldFilter={!isServer}
190
+ filter={filterByKeywords}
185
191
  className="flex h-full w-full flex-col overflow-hidden"
186
192
  >
187
193
  <div className="flex items-center border-b px-3">
@@ -204,8 +210,8 @@ export function MultiCombobox({
204
210
  {options.map((option) => (
205
211
  <CommandPrimitive.Item
206
212
  key={option.value}
207
- value={isServer ? option.value : option.label}
208
- keywords={option.description ? [option.description] : undefined}
213
+ value={option.value}
214
+ keywords={optionKeywords(option)}
209
215
  disabled={option.disabled}
210
216
  onSelect={() => toggleValue(option.value)}
211
217
  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"
@@ -19,9 +19,12 @@ export interface RatingProps {
19
19
  "aria-label"?: string
20
20
  }
21
21
 
22
+ const formatNote = (value: number) =>
23
+ value.toLocaleString("pt-BR", { maximumFractionDigits: 1 })
24
+
22
25
  function defaultLabel(value: number) {
23
26
  return value > 0
24
- ? `Avaliação: ${value} de ${MAX_STARS} estrelas`
27
+ ? `Nota ${formatNote(value)} de ${MAX_STARS}`
25
28
  : "Avaliação: sem nota"
26
29
  }
27
30
 
@@ -43,18 +46,32 @@ export function Rating({
43
46
  aria-label={label}
44
47
  className={cn("inline-flex items-center gap-0.5", className)}
45
48
  >
46
- {Array.from({ length: MAX_STARS }, (_, index) => (
47
- <Star
48
- key={index}
49
- aria-hidden
50
- className={cn(
51
- sizeClasses[size],
52
- index < clamped
53
- ? "fill-amber-400 text-amber-400"
54
- : "text-muted-foreground",
55
- )}
56
- />
57
- ))}
49
+ {Array.from({ length: MAX_STARS }, (_, index) => {
50
+ // Média 4,3 pinta 30% da quinta estrela: a fração é a nota, não um arredondamento.
51
+ const fill = Math.min(1, Math.max(0, clamped - index))
52
+ return (
53
+ <span key={index} className="relative inline-flex">
54
+ <Star
55
+ aria-hidden
56
+ className={cn(sizeClasses[size], "text-muted-foreground")}
57
+ />
58
+ {fill > 0 && (
59
+ <span
60
+ className="absolute inset-y-0 left-0 overflow-hidden"
61
+ style={{ width: `${fill * 100}%` }}
62
+ >
63
+ <Star
64
+ aria-hidden
65
+ className={cn(
66
+ sizeClasses[size],
67
+ "max-w-none fill-amber-400 text-amber-400",
68
+ )}
69
+ />
70
+ </span>
71
+ )}
72
+ </span>
73
+ )
74
+ })}
58
75
  </div>
59
76
  )
60
77
  }