@motor-hero/ui-kit 0.49.0 → 0.51.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/dist/components/combobox.d.ts +7 -0
- package/dist/components/combobox.d.ts.map +1 -1
- package/dist/components/multi-combobox.d.ts +1 -1
- package/dist/components/multi-combobox.d.ts.map +1 -1
- package/dist/components/pagination.d.ts +13 -4
- package/dist/components/pagination.d.ts.map +1 -1
- package/dist/index.cjs +88 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +81 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/combobox.tsx +22 -1
- package/src/components/multi-combobox.tsx +14 -3
- package/src/components/pagination.tsx +79 -7
package/package.json
CHANGED
|
@@ -14,6 +14,10 @@ export interface ComboboxOption {
|
|
|
14
14
|
/** Optional leading decoration rendered before the label (icon, colored
|
|
15
15
|
* dot) — purely visual, doesn't affect filtering/matching. */
|
|
16
16
|
icon?: React.ReactNode;
|
|
17
|
+
/** Linha secundária abaixo do rótulo na lista (ex.: e-mail de uma pessoa,
|
|
18
|
+
* para separar homônimos). No gatilho vira o `title`. Entra na busca do
|
|
19
|
+
* modo cliente. */
|
|
20
|
+
description?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ComboboxProps {
|
|
@@ -209,6 +213,7 @@ export function Combobox({
|
|
|
209
213
|
// cliente: cmdk filtra pelo `value`, então usamos o label; servidor:
|
|
210
214
|
// o servidor já filtrou, então usamos o id (único, evita colisão de labels).
|
|
211
215
|
value={isServer ? option.value : option.label}
|
|
216
|
+
keywords={option.description ? [option.description] : undefined}
|
|
212
217
|
disabled={option.disabled}
|
|
213
218
|
onSelect={() => handleSelect(option.value)}
|
|
214
219
|
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"
|
|
@@ -220,7 +225,7 @@ export function Combobox({
|
|
|
220
225
|
)}
|
|
221
226
|
/>
|
|
222
227
|
{option.icon}
|
|
223
|
-
<
|
|
228
|
+
<OptionText option={option} />
|
|
224
229
|
</CommandPrimitive.Item>
|
|
225
230
|
))}
|
|
226
231
|
{isServer && !loading && options.length === 0 && (
|
|
@@ -274,3 +279,19 @@ export function Combobox({
|
|
|
274
279
|
</DrawerPrimitive.Root>
|
|
275
280
|
);
|
|
276
281
|
}
|
|
282
|
+
|
|
283
|
+
export function OptionText({ option }: { option: ComboboxOption }) {
|
|
284
|
+
if (!option.description)
|
|
285
|
+
return <span className="truncate">{option.label}</span>;
|
|
286
|
+
return (
|
|
287
|
+
<span className="flex min-w-0 flex-col">
|
|
288
|
+
<span className="truncate">{option.label}</span>
|
|
289
|
+
<span
|
|
290
|
+
title={option.description}
|
|
291
|
+
className="truncate text-xs text-muted-foreground"
|
|
292
|
+
>
|
|
293
|
+
{option.description}
|
|
294
|
+
</span>
|
|
295
|
+
</span>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -6,7 +6,7 @@ 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
|
|
9
|
+
import { type ComboboxOption, OptionText } from "./combobox"
|
|
10
10
|
|
|
11
11
|
export interface MultiComboboxProps {
|
|
12
12
|
options: ComboboxOption[]
|
|
@@ -101,6 +101,15 @@ export function MultiCombobox({
|
|
|
101
101
|
.map((v) => byValue.get(v) ?? labelCache.current.get(v))
|
|
102
102
|
.filter((option): option is ComboboxOption => option !== undefined)
|
|
103
103
|
|
|
104
|
+
// Chips homônimos não se distinguem só pelo rótulo: esses ganham a descrição.
|
|
105
|
+
const labelCounts = new Map<string, number>()
|
|
106
|
+
for (const option of selected)
|
|
107
|
+
labelCounts.set(option.label, (labelCounts.get(option.label) ?? 0) + 1)
|
|
108
|
+
const chipLabel = (option: ComboboxOption) =>
|
|
109
|
+
option.description && (labelCounts.get(option.label) ?? 0) > 1
|
|
110
|
+
? `${option.label} · ${option.description}`
|
|
111
|
+
: option.label
|
|
112
|
+
|
|
104
113
|
const { listRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
105
114
|
enabled: isServer && !!hasMore,
|
|
106
115
|
loading,
|
|
@@ -146,9 +155,10 @@ export function MultiCombobox({
|
|
|
146
155
|
selected.map((option) => (
|
|
147
156
|
<span
|
|
148
157
|
key={option.value}
|
|
158
|
+
title={option.description}
|
|
149
159
|
className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground"
|
|
150
160
|
>
|
|
151
|
-
{option
|
|
161
|
+
{chipLabel(option)}
|
|
152
162
|
<button
|
|
153
163
|
type="button"
|
|
154
164
|
tabIndex={-1}
|
|
@@ -195,6 +205,7 @@ export function MultiCombobox({
|
|
|
195
205
|
<CommandPrimitive.Item
|
|
196
206
|
key={option.value}
|
|
197
207
|
value={isServer ? option.value : option.label}
|
|
208
|
+
keywords={option.description ? [option.description] : undefined}
|
|
198
209
|
disabled={option.disabled}
|
|
199
210
|
onSelect={() => toggleValue(option.value)}
|
|
200
211
|
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"
|
|
@@ -205,7 +216,7 @@ export function MultiCombobox({
|
|
|
205
216
|
value.includes(option.value) ? "opacity-100" : "opacity-0",
|
|
206
217
|
)}
|
|
207
218
|
/>
|
|
208
|
-
<
|
|
219
|
+
<OptionText option={option} />
|
|
209
220
|
</CommandPrimitive.Item>
|
|
210
221
|
))}
|
|
211
222
|
{isServer && !loading && options.length === 0 && (
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { useId, useState } from "react"
|
|
2
|
+
|
|
3
|
+
interface PaginationBaseProps {
|
|
2
4
|
page: number
|
|
3
5
|
onPageChange: (page: number) => void
|
|
4
6
|
hasNextPage: boolean
|
|
5
7
|
hasPreviousPage: boolean
|
|
6
|
-
/** Total de páginas. Quando informado, o rótulo vira "Página {page} de {totalPages}". */
|
|
7
|
-
totalPages?: number
|
|
8
8
|
/** Exibe o seletor de itens por página. Use com `pageSize`, `onPageSizeChange` e `pageSizeOptions`. */
|
|
9
9
|
showPageSizeSelector?: boolean
|
|
10
10
|
pageSize?: number
|
|
@@ -13,6 +13,20 @@ export interface PaginationProps {
|
|
|
13
13
|
className?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
type PageJumpProps =
|
|
17
|
+
| {
|
|
18
|
+
/** Total de páginas. Quando informado, o rótulo vira "Página {page} de {totalPages}". */
|
|
19
|
+
totalPages?: number
|
|
20
|
+
showPageJump?: false
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
totalPages: number
|
|
24
|
+
/** Torna o número da página editável para pular direto a uma página. Exige `totalPages`. */
|
|
25
|
+
showPageJump: true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PaginationProps = PaginationBaseProps & PageJumpProps
|
|
29
|
+
|
|
16
30
|
// `max-sm:h-10`: alvo de toque de 40px no celular; o desktop segue em 32px.
|
|
17
31
|
const navButtonClass =
|
|
18
32
|
"inline-flex h-8 max-sm:h-10 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer disabled:pointer-events-none disabled:opacity-50"
|
|
@@ -23,6 +37,7 @@ export function Pagination({
|
|
|
23
37
|
hasNextPage,
|
|
24
38
|
hasPreviousPage,
|
|
25
39
|
totalPages,
|
|
40
|
+
showPageJump = false,
|
|
26
41
|
showPageSizeSelector = false,
|
|
27
42
|
pageSize,
|
|
28
43
|
onPageSizeChange,
|
|
@@ -64,10 +79,14 @@ export function Pagination({
|
|
|
64
79
|
>
|
|
65
80
|
Anterior
|
|
66
81
|
</button>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
{showPageJump && totalPages !== undefined ? (
|
|
83
|
+
<PageJumpInput page={page} totalPages={totalPages} onPageChange={onPageChange} />
|
|
84
|
+
) : (
|
|
85
|
+
<span className="text-sm text-muted-foreground">
|
|
86
|
+
Página {page}
|
|
87
|
+
{totalPages ? ` de ${totalPages}` : ""}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
71
90
|
<button
|
|
72
91
|
type="button"
|
|
73
92
|
onClick={() => onPageChange(page + 1)}
|
|
@@ -80,3 +99,56 @@ export function Pagination({
|
|
|
80
99
|
</div>
|
|
81
100
|
)
|
|
82
101
|
}
|
|
102
|
+
|
|
103
|
+
interface PageJumpInputProps {
|
|
104
|
+
page: number
|
|
105
|
+
totalPages: number
|
|
106
|
+
onPageChange: (page: number) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function PageJumpInput({ page, totalPages, onPageChange }: PageJumpInputProps) {
|
|
110
|
+
const inputId = useId()
|
|
111
|
+
// `null` = sem edição em andamento; o campo mostra a página atual.
|
|
112
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
113
|
+
const [prevPage, setPrevPage] = useState(page)
|
|
114
|
+
if (page !== prevPage) {
|
|
115
|
+
setPrevPage(page)
|
|
116
|
+
setDraft(null)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Enter e blur confirmam; valores fora do intervalo são ajustados em silêncio.
|
|
120
|
+
const commit = () => {
|
|
121
|
+
if (draft === null) return
|
|
122
|
+
setDraft(null)
|
|
123
|
+
if (draft === "") return
|
|
124
|
+
const target = Math.max(1, Math.min(Number(draft), totalPages))
|
|
125
|
+
if (target !== page) onPageChange(target)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<span className="flex items-center gap-1.5 whitespace-nowrap text-sm text-muted-foreground">
|
|
130
|
+
<label htmlFor={inputId} className="max-sm:sr-only">
|
|
131
|
+
Página
|
|
132
|
+
</label>
|
|
133
|
+
<input
|
|
134
|
+
id={inputId}
|
|
135
|
+
type="text"
|
|
136
|
+
inputMode="numeric"
|
|
137
|
+
enterKeyHint="go"
|
|
138
|
+
autoComplete="off"
|
|
139
|
+
aria-label="Ir para a página"
|
|
140
|
+
value={draft ?? String(page)}
|
|
141
|
+
onChange={(e) => setDraft(e.target.value.replace(/\D/g, ""))}
|
|
142
|
+
onFocus={(e) => e.target.select()}
|
|
143
|
+
onBlur={commit}
|
|
144
|
+
onKeyDown={(e) => {
|
|
145
|
+
if (e.key === "Enter") commit()
|
|
146
|
+
else if (e.key === "Escape") setDraft(null)
|
|
147
|
+
}}
|
|
148
|
+
style={{ width: `${String(totalPages).length + 3}ch` }}
|
|
149
|
+
className="h-8 max-sm:h-10 rounded-md border border-input bg-background px-1.5 text-center text-xs font-medium tabular-nums text-foreground shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
150
|
+
/>
|
|
151
|
+
<span>de {totalPages}</span>
|
|
152
|
+
</span>
|
|
153
|
+
)
|
|
154
|
+
}
|