@motor-hero/ui-kit 0.36.2 → 0.37.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.36.2",
3
+ "version": "0.37.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",
@@ -60,6 +60,7 @@
60
60
  "@radix-ui/react-dropdown-menu": "^2.1.0",
61
61
  "@radix-ui/react-popover": "^1.1.16",
62
62
  "@radix-ui/react-slot": "^1.1.0",
63
+ "@radix-ui/react-toggle-group": "^1.1.0",
63
64
  "@react-input/mask": "^2.0.4",
64
65
  "class-variance-authority": "^0.7.0",
65
66
  "cmdk": "^1.1.1",
@@ -1,51 +1,54 @@
1
- import * as PopoverPrimitive from "@radix-ui/react-popover"
2
- import { Command as CommandPrimitive } from "cmdk"
3
- import { Check, ChevronsUpDown, Loader2, Plus, Search } from "lucide-react"
4
- import * as React from "react"
5
- import { Drawer as DrawerPrimitive } from "vaul"
6
- import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel"
7
- import { useIsDesktop } from "../hooks/use-is-desktop"
8
- import { cn } from "../lib/utils"
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
+ import { Command as CommandPrimitive } from "cmdk";
3
+ import { Check, ChevronsUpDown, Loader2, Plus, Search } from "lucide-react";
4
+ import * as React from "react";
5
+ import { Drawer as DrawerPrimitive } from "vaul";
6
+ import { useInfiniteScrollSentinel } from "../hooks/use-infinite-scroll-sentinel";
7
+ import { useIsDesktop } from "../hooks/use-is-desktop";
8
+ import { cn } from "../lib/utils";
9
9
 
10
10
  export interface ComboboxOption {
11
- value: string
12
- label: string
13
- disabled?: boolean
11
+ value: string;
12
+ label: string;
13
+ disabled?: boolean;
14
14
  }
15
15
 
16
16
  export interface ComboboxProps {
17
- options: ComboboxOption[]
18
- value?: string
19
- onChange: (value: string) => void
20
- /** Texto do gatilho quando nada está selecionado */
21
- placeholder?: string
22
- /** Placeholder do campo de busca */
23
- searchPlaceholder?: string
24
- /** Mensagem quando a busca não retorna nada */
25
- emptyMessage?: string
26
- disabled?: boolean
27
- id?: string
28
- className?: string
29
- "aria-invalid"?: boolean | "true" | "false"
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
17
+ options: ComboboxOption[];
18
+ value?: string;
19
+ onChange: (value: string) => void;
20
+ /** Texto do gatilho quando nada está selecionado */
21
+ placeholder?: string;
22
+ /** Placeholder do campo de busca */
23
+ searchPlaceholder?: string;
24
+ /** Mensagem quando a busca não retorna nada */
25
+ emptyMessage?: string;
26
+ disabled?: boolean;
27
+ id?: string;
28
+ className?: string;
29
+ "aria-invalid"?: boolean | "true" | "false";
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;
49
+ /** Permite limpar a seleção clicando na opção já selecionada (útil em
50
+ * filtros: remover um filtro sem uma opção "Todos"). Emite `onChange("")`. */
51
+ deselectable?: boolean;
49
52
  }
50
53
 
51
54
  /**
@@ -63,190 +66,189 @@ export interface ComboboxProps {
63
66
  * estado de erro) e fica com a borda vermelha quando inválido.
64
67
  */
65
68
  export function Combobox({
66
- options,
67
- value,
68
- onChange,
69
- placeholder = "Selecione...",
70
- searchPlaceholder = "Buscar...",
71
- emptyMessage = "Nenhum resultado encontrado.",
72
- disabled,
73
- id,
74
- className,
75
- "aria-invalid": ariaInvalid,
76
- "aria-describedby": ariaDescribedby,
77
- onSearchChange,
78
- onLoadMore,
79
- loading,
80
- hasMore,
81
- selectedOption,
82
- creatable,
83
- formatCreateLabel,
69
+ options,
70
+ value,
71
+ onChange,
72
+ placeholder = "Selecione...",
73
+ searchPlaceholder = "Buscar...",
74
+ emptyMessage = "Nenhum resultado encontrado.",
75
+ disabled,
76
+ id,
77
+ className,
78
+ "aria-invalid": ariaInvalid,
79
+ "aria-describedby": ariaDescribedby,
80
+ onSearchChange,
81
+ onLoadMore,
82
+ loading,
83
+ hasMore,
84
+ selectedOption,
85
+ creatable,
86
+ formatCreateLabel,
87
+ deselectable,
84
88
  }: ComboboxProps) {
85
- const [open, setOpen] = React.useState(false)
86
- const [search, setSearch] = React.useState("")
87
- const isDesktop = useIsDesktop()
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))
89
+ const [open, setOpen] = React.useState(false);
90
+ const [search, setSearch] = React.useState("");
91
+ const isDesktop = useIsDesktop();
92
+ const isServer = onSearchChange !== undefined;
93
+ const selected =
94
+ selectedOption?.value === value
95
+ ? selectedOption
96
+ : (options.find((option) => option.value === value) ??
97
+ (creatable && value ? { value, label: value } : undefined));
94
98
 
95
- const handleOpenChange = (next: boolean) => {
96
- setOpen(next)
97
- if (!next) setSearch("")
98
- }
99
+ const handleOpenChange = (next: boolean) => {
100
+ setOpen(next);
101
+ if (!next) setSearch("");
102
+ };
99
103
 
100
- const handleSelect = (next: string) => {
101
- onChange(next)
102
- handleOpenChange(false)
103
- }
104
+ const handleSelect = (next: string) => {
105
+ onChange(deselectable && next === value ? "" : next);
106
+ handleOpenChange(false);
107
+ };
104
108
 
105
- const handleSearchChange = (next: string) => {
106
- setSearch(next)
107
- onSearchChange?.(next)
108
- }
109
+ const handleSearchChange = (next: string) => {
110
+ setSearch(next);
111
+ onSearchChange?.(next);
112
+ };
109
113
 
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
- })
114
+ const trimmedSearch = search.trim();
115
+ const showCreate =
116
+ !!creatable &&
117
+ trimmedSearch.length > 0 &&
118
+ !options.some(
119
+ (option) => option.value.toLowerCase() === trimmedSearch.toLowerCase(),
120
+ );
121
+ const { listRef, sentinelRef } = useInfiniteScrollSentinel({
122
+ enabled: isServer && !!hasMore,
123
+ loading,
124
+ onLoadMore,
125
+ });
122
126
 
123
- const trigger = (
124
- <button
125
- type="button"
126
- id={id}
127
- role="combobox"
128
- aria-expanded={open}
129
- aria-invalid={ariaInvalid}
130
- aria-describedby={ariaDescribedby}
131
- disabled={disabled}
132
- className={cn(
133
- "flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:border-ring/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
134
- className,
135
- )}
136
- >
137
- <span className={cn("truncate", !selected && "text-muted-foreground")}>
138
- {selected ? selected.label : placeholder}
139
- </span>
140
- <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
141
- </button>
142
- )
127
+ const trigger = (
128
+ <button
129
+ type="button"
130
+ id={id}
131
+ role="combobox"
132
+ aria-expanded={open}
133
+ aria-invalid={ariaInvalid}
134
+ aria-describedby={ariaDescribedby}
135
+ disabled={disabled}
136
+ className={cn(
137
+ "flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:border-ring/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
138
+ className,
139
+ )}
140
+ >
141
+ <span className={cn("truncate", !selected && "text-muted-foreground")}>
142
+ {selected ? selected.label : placeholder}
143
+ </span>
144
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
145
+ </button>
146
+ );
143
147
 
144
- const command = (
145
- <CommandPrimitive
146
- shouldFilter={!isServer}
147
- className="flex h-full w-full flex-col overflow-hidden"
148
- >
149
- <div className="flex items-center border-b px-3">
150
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
151
- <CommandPrimitive.Input
152
- placeholder={searchPlaceholder}
153
- onValueChange={
154
- isServer || creatable ? handleSearchChange : undefined
155
- }
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"
157
- />
158
- </div>
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
- )}
183
- {options.map((option) => (
184
- <CommandPrimitive.Item
185
- key={option.value}
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}
189
- disabled={option.disabled}
190
- onSelect={() => handleSelect(option.value)}
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"
192
- >
193
- <Check
194
- className={cn(
195
- "h-4 w-4 shrink-0",
196
- value === option.value ? "opacity-100" : "opacity-0",
197
- )}
198
- />
199
- <span className="truncate">{option.label}</span>
200
- </CommandPrimitive.Item>
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
- )}
215
- </CommandPrimitive.List>
216
- </CommandPrimitive>
217
- )
148
+ const command = (
149
+ <CommandPrimitive
150
+ shouldFilter={!isServer}
151
+ className="flex h-full w-full flex-col overflow-hidden"
152
+ >
153
+ <div className="flex items-center border-b px-3">
154
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
155
+ <CommandPrimitive.Input
156
+ placeholder={searchPlaceholder}
157
+ onValueChange={isServer || creatable ? handleSearchChange : undefined}
158
+ className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
159
+ />
160
+ </div>
161
+ <CommandPrimitive.List
162
+ ref={listRef}
163
+ className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60"
164
+ >
165
+ {!isServer && !showCreate && (
166
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
167
+ {emptyMessage}
168
+ </CommandPrimitive.Empty>
169
+ )}
170
+ {showCreate && (
171
+ <CommandPrimitive.Item
172
+ // cmdk pontua pelo `value`; igualar à busca mantém a linha sempre no topo.
173
+ value={trimmedSearch}
174
+ onSelect={() => handleSelect(trimmedSearch)}
175
+ 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"
176
+ >
177
+ <Plus className="h-4 w-4 shrink-0" />
178
+ <span className="truncate">
179
+ {(formatCreateLabel ?? ((text) => `Usar "${text}"`))(
180
+ trimmedSearch,
181
+ )}
182
+ </span>
183
+ </CommandPrimitive.Item>
184
+ )}
185
+ {options.map((option) => (
186
+ <CommandPrimitive.Item
187
+ key={option.value}
188
+ // cliente: cmdk filtra pelo `value`, então usamos o label; servidor:
189
+ // o servidor já filtrou, então usamos o id (único, evita colisão de labels).
190
+ value={isServer ? option.value : option.label}
191
+ disabled={option.disabled}
192
+ onSelect={() => handleSelect(option.value)}
193
+ 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"
194
+ >
195
+ <Check
196
+ className={cn(
197
+ "h-4 w-4 shrink-0",
198
+ value === option.value ? "opacity-100" : "opacity-0",
199
+ )}
200
+ />
201
+ <span className="truncate">{option.label}</span>
202
+ </CommandPrimitive.Item>
203
+ ))}
204
+ {isServer && !loading && options.length === 0 && (
205
+ <div className="py-6 text-center text-sm text-muted-foreground">
206
+ {emptyMessage}
207
+ </div>
208
+ )}
209
+ {isServer && loading && (
210
+ <div className="flex items-center justify-center py-6">
211
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
212
+ </div>
213
+ )}
214
+ {isServer && hasMore && (
215
+ <div ref={sentinelRef} aria-hidden className="h-1" />
216
+ )}
217
+ </CommandPrimitive.List>
218
+ </CommandPrimitive>
219
+ );
218
220
 
219
- if (isDesktop) {
220
- return (
221
- <PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange}>
222
- <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
223
- <PopoverPrimitive.Portal>
224
- <PopoverPrimitive.Content
225
- align="start"
226
- sideOffset={4}
227
- className="pointer-events-auto z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
228
- style={{ width: "var(--radix-popover-trigger-width)" }}
229
- >
230
- {command}
231
- </PopoverPrimitive.Content>
232
- </PopoverPrimitive.Portal>
233
- </PopoverPrimitive.Root>
234
- )
235
- }
221
+ if (isDesktop) {
222
+ return (
223
+ <PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange}>
224
+ <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
225
+ <PopoverPrimitive.Portal>
226
+ <PopoverPrimitive.Content
227
+ align="start"
228
+ sideOffset={4}
229
+ className="pointer-events-auto z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
230
+ style={{ width: "var(--radix-popover-trigger-width)" }}
231
+ >
232
+ {command}
233
+ </PopoverPrimitive.Content>
234
+ </PopoverPrimitive.Portal>
235
+ </PopoverPrimitive.Root>
236
+ );
237
+ }
236
238
 
237
- return (
238
- <DrawerPrimitive.Root open={open} onOpenChange={handleOpenChange}>
239
- <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
240
- <DrawerPrimitive.Portal>
241
- <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
242
- <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[85vh] flex-col rounded-t-2xl border bg-popover text-popover-foreground outline-none">
243
- <DrawerPrimitive.Title className="sr-only">
244
- {placeholder}
245
- </DrawerPrimitive.Title>
246
- <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
247
- {command}
248
- </DrawerPrimitive.Content>
249
- </DrawerPrimitive.Portal>
250
- </DrawerPrimitive.Root>
251
- )
239
+ return (
240
+ <DrawerPrimitive.Root open={open} onOpenChange={handleOpenChange}>
241
+ <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
242
+ <DrawerPrimitive.Portal>
243
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
244
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[85vh] flex-col rounded-t-2xl border bg-popover text-popover-foreground outline-none">
245
+ <DrawerPrimitive.Title className="sr-only">
246
+ {placeholder}
247
+ </DrawerPrimitive.Title>
248
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
249
+ {command}
250
+ </DrawerPrimitive.Content>
251
+ </DrawerPrimitive.Portal>
252
+ </DrawerPrimitive.Root>
253
+ );
252
254
  }
@@ -0,0 +1,99 @@
1
+ import * as ToggleGroup from "@radix-ui/react-toggle-group"
2
+ import { Star } from "lucide-react"
3
+ import { cn } from "../lib/utils"
4
+
5
+ const MAX_STARS = 5
6
+
7
+ const sizeClasses = {
8
+ sm: "h-4 w-4",
9
+ md: "h-5 w-5",
10
+ lg: "h-6 w-6",
11
+ } as const
12
+
13
+ export interface RatingProps {
14
+ value?: number
15
+ onChange?: (value: number) => void
16
+ readOnly?: boolean
17
+ size?: "sm" | "md" | "lg"
18
+ className?: string
19
+ "aria-label"?: string
20
+ }
21
+
22
+ function defaultLabel(value: number) {
23
+ return value > 0
24
+ ? `Avaliação: ${value} de ${MAX_STARS} estrelas`
25
+ : "Avaliação: sem nota"
26
+ }
27
+
28
+ export function Rating({
29
+ value = 0,
30
+ onChange,
31
+ readOnly = false,
32
+ size = "md",
33
+ className,
34
+ "aria-label": ariaLabel,
35
+ }: RatingProps) {
36
+ const clamped = Math.min(MAX_STARS, Math.max(0, value))
37
+ const label = ariaLabel ?? defaultLabel(clamped)
38
+
39
+ if (readOnly) {
40
+ return (
41
+ <div
42
+ role="img"
43
+ aria-label={label}
44
+ className={cn("inline-flex items-center gap-0.5", className)}
45
+ >
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
+ ))}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <ToggleGroup.Root
64
+ type="single"
65
+ value={clamped > 0 ? String(clamped) : ""}
66
+ onValueChange={(next) => {
67
+ // Radix emite "" ao clicar de novo na estrela já selecionada;
68
+ // mantemos a nota atual em vez de zerar (onChange é sempre 1-5).
69
+ if (next) onChange?.(Number(next))
70
+ }}
71
+ aria-label={label}
72
+ className={cn("inline-flex items-center gap-0.5", className)}
73
+ >
74
+ {Array.from({ length: MAX_STARS }, (_, index) => {
75
+ const starValue = index + 1
76
+ const filled = starValue <= clamped
77
+ return (
78
+ <ToggleGroup.Item
79
+ key={starValue}
80
+ value={String(starValue)}
81
+ aria-label={`${starValue} ${starValue === 1 ? "estrela" : "estrelas"}`}
82
+ className="inline-flex cursor-pointer items-center justify-center rounded-sm bg-transparent p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
83
+ >
84
+ <Star
85
+ aria-hidden
86
+ className={cn(
87
+ sizeClasses[size],
88
+ "transition-colors",
89
+ filled
90
+ ? "fill-amber-400 text-amber-400"
91
+ : "text-muted-foreground hover:text-amber-400",
92
+ )}
93
+ />
94
+ </ToggleGroup.Item>
95
+ )
96
+ })}
97
+ </ToggleGroup.Root>
98
+ )
99
+ }