@strategicos/ui 0.4.1 → 0.6.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/README.md +22 -1
- package/dist/components/accordion.d.ts +10 -0
- package/dist/components/accordion.d.ts.map +1 -0
- package/dist/components/accordion.js +21 -0
- package/dist/components/accordion.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/chip.d.ts +40 -0
- package/dist/components/chip.d.ts.map +1 -0
- package/dist/components/chip.js +23 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/dialog.d.ts +3 -1
- package/dist/components/dialog.d.ts.map +1 -1
- package/dist/components/dialog.js +12 -2
- package/dist/components/dialog.js.map +1 -1
- package/dist/components/dropzone.d.ts +38 -0
- package/dist/components/dropzone.d.ts.map +1 -0
- package/dist/components/dropzone.js +67 -0
- package/dist/components/dropzone.js.map +1 -0
- package/dist/components/empty-state.d.ts +24 -0
- package/dist/components/empty-state.d.ts.map +1 -0
- package/dist/components/empty-state.js +21 -0
- package/dist/components/empty-state.js.map +1 -0
- package/dist/components/multi-select.d.ts +44 -0
- package/dist/components/multi-select.d.ts.map +1 -0
- package/dist/components/multi-select.js +72 -0
- package/dist/components/multi-select.js.map +1 -0
- package/dist/components/page-header.d.ts +21 -0
- package/dist/components/page-header.d.ts.map +1 -0
- package/dist/components/page-header.js +20 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/popover.d.ts +10 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.js +20 -0
- package/dist/components/popover.js.map +1 -0
- package/dist/components/progress.d.ts +18 -0
- package/dist/components/progress.d.ts.map +1 -0
- package/dist/components/progress.js +18 -0
- package/dist/components/progress.js.map +1 -0
- package/dist/components/segmented-control.d.ts +37 -0
- package/dist/components/segmented-control.d.ts.map +1 -0
- package/dist/components/segmented-control.js +26 -0
- package/dist/components/segmented-control.js.map +1 -0
- package/dist/components/sheet.d.ts +11 -1
- package/dist/components/sheet.d.ts.map +1 -1
- package/dist/components/sheet.js +10 -2
- package/dist/components/sheet.js.map +1 -1
- package/dist/components/sidebar.d.ts.map +1 -1
- package/dist/components/sidebar.js +4 -2
- package/dist/components/sidebar.js.map +1 -1
- package/dist/components/stat.d.ts +27 -0
- package/dist/components/stat.d.ts.map +1 -0
- package/dist/components/stat.js +23 -0
- package/dist/components/stat.js.map +1 -0
- package/dist/components/tabs.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/accordion.tsx +55 -0
- package/src/components/chip.tsx +111 -0
- package/src/components/dialog.tsx +20 -2
- package/src/components/dropzone.tsx +124 -0
- package/src/components/empty-state.tsx +56 -0
- package/src/components/multi-select.tsx +206 -0
- package/src/components/page-header.tsx +44 -0
- package/src/components/popover.tsx +44 -0
- package/src/components/progress.tsx +50 -0
- package/src/components/segmented-control.tsx +89 -0
- package/src/components/sheet.tsx +14 -1
- package/src/components/sidebar.tsx +5 -3
- package/src/components/stat.tsx +65 -0
|
@@ -47,21 +47,39 @@ function DialogOverlay({
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Posição e tamanho por breakpoint. No celular (`fullscreen`, o padrão) o
|
|
51
|
+
* diálogo ocupa a tela inteira e rola por dentro, como os modais do Mantine
|
|
52
|
+
* com `fullScreen` em telas pequenas; a partir de `sm` volta a ser centrado.
|
|
53
|
+
* `centered` mantém o diálogo centrado também no celular, para conteúdos
|
|
54
|
+
* curtos. Sem `translate` no celular: ele criaria um bloco de contenção e o
|
|
55
|
+
* botão de fechar (`fixed`) rolaria junto com o conteúdo. */
|
|
56
|
+
const dialogPlacement = {
|
|
57
|
+
fullscreen:
|
|
58
|
+
"inset-0 h-dvh w-full overflow-y-auto rounded-none content-start sm:inset-auto sm:top-1/2 sm:left-1/2 sm:h-auto sm:max-h-[calc(100dvh-2rem)] sm:max-w-[440px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-lg",
|
|
59
|
+
centered:
|
|
60
|
+
"top-1/2 left-1/2 max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg sm:max-w-[440px]",
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
function DialogContent({
|
|
51
64
|
className,
|
|
52
65
|
children,
|
|
53
66
|
showCloseButton = true,
|
|
67
|
+
mobile = "fullscreen",
|
|
54
68
|
...props
|
|
55
69
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
56
70
|
showCloseButton?: boolean
|
|
71
|
+
/** Comportamento abaixo de `sm`. Padrão: tela cheia. */
|
|
72
|
+
mobile?: "fullscreen" | "centered"
|
|
57
73
|
}) {
|
|
58
74
|
return (
|
|
59
75
|
<DialogPortal>
|
|
60
76
|
<DialogOverlay />
|
|
61
77
|
<DialogPrimitive.Content
|
|
62
78
|
data-slot="dialog-content"
|
|
79
|
+
data-mobile={mobile}
|
|
63
80
|
className={cn(
|
|
64
|
-
"fixed
|
|
81
|
+
"fixed z-50 grid gap-4 bg-popover p-4 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
82
|
+
dialogPlacement[mobile],
|
|
65
83
|
className
|
|
66
84
|
)}
|
|
67
85
|
{...props}
|
|
@@ -71,7 +89,7 @@ function DialogContent({
|
|
|
71
89
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
|
72
90
|
<Button
|
|
73
91
|
variant="ghost"
|
|
74
|
-
className="
|
|
92
|
+
className={cn("top-3 right-3 z-10", mobile === "fullscreen" ? "fixed sm:absolute" : "absolute")}
|
|
75
93
|
size="icon-sm"
|
|
76
94
|
>
|
|
77
95
|
<XIcon
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { UploadIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Área de envio de arquivos, como o Dropzone do Mantine: arrastar e soltar, ou
|
|
10
|
+
* clicar (e Enter/Espaço) para abrir o seletor do sistema.
|
|
11
|
+
*
|
|
12
|
+
* <Dropzone
|
|
13
|
+
* accept=".txt,.xml,.zip"
|
|
14
|
+
* maxSize={50 * 1024 * 1024}
|
|
15
|
+
* onDrop={files => enviar(files)}
|
|
16
|
+
* onReject={msg => toast.error(msg)}
|
|
17
|
+
* title="Importar arquivos fiscais"
|
|
18
|
+
* description="SPED (.txt), NF-e (.xml) ou pacote .zip, até 50 MB"
|
|
19
|
+
* />
|
|
20
|
+
*
|
|
21
|
+
* `children` substitui o conteúdo padrão (ícone, título e descrição).
|
|
22
|
+
* A validação de tipo e tamanho é só uma conveniência: o servidor valida de
|
|
23
|
+
* novo.
|
|
24
|
+
*/
|
|
25
|
+
function Dropzone({
|
|
26
|
+
onDrop,
|
|
27
|
+
onReject,
|
|
28
|
+
accept,
|
|
29
|
+
multiple = false,
|
|
30
|
+
maxSize,
|
|
31
|
+
disabled = false,
|
|
32
|
+
loading = false,
|
|
33
|
+
title = "Arraste arquivos ou clique para escolher",
|
|
34
|
+
description,
|
|
35
|
+
icon,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
}: {
|
|
39
|
+
onDrop: (files: File[]) => void
|
|
40
|
+
/** Chamado com uma mensagem quando algum arquivo é recusado (tipo/tamanho). */
|
|
41
|
+
onReject?: (message: string) => void
|
|
42
|
+
/** Mesmo formato do atributo `accept` do input (ex.: ".pdf,image/*"). */
|
|
43
|
+
accept?: string
|
|
44
|
+
multiple?: boolean
|
|
45
|
+
/** Tamanho máximo por arquivo, em bytes. */
|
|
46
|
+
maxSize?: number
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
/** Enquanto envia: bloqueia novos envios e mostra o estado ocupado. */
|
|
49
|
+
loading?: boolean
|
|
50
|
+
title?: React.ReactNode
|
|
51
|
+
description?: React.ReactNode
|
|
52
|
+
icon?: React.ReactNode
|
|
53
|
+
className?: string
|
|
54
|
+
children?: React.ReactNode
|
|
55
|
+
}) {
|
|
56
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
57
|
+
const [dragging, setDragging] = React.useState(false)
|
|
58
|
+
const blocked = disabled || loading
|
|
59
|
+
|
|
60
|
+
function matchesAccept(file: File) {
|
|
61
|
+
if (!accept) return true
|
|
62
|
+
const name = file.name.toLowerCase()
|
|
63
|
+
return accept.split(",").map(s => s.trim().toLowerCase()).some(rule => {
|
|
64
|
+
if (rule.startsWith(".")) return name.endsWith(rule)
|
|
65
|
+
if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1))
|
|
66
|
+
return file.type === rule
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleFiles(list: FileList | null) {
|
|
71
|
+
if (!list || list.length === 0 || blocked) return
|
|
72
|
+
const files = Array.from(list).slice(0, multiple ? undefined : 1)
|
|
73
|
+
const ok: File[] = []
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
if (!matchesAccept(f)) onReject?.(`"${f.name}" não é de um tipo aceito.`)
|
|
76
|
+
else if (maxSize && f.size > maxSize) onReject?.(`"${f.name}" passa do limite de ${Math.round(maxSize / 1024 / 1024)} MB.`)
|
|
77
|
+
else ok.push(f)
|
|
78
|
+
}
|
|
79
|
+
if (ok.length) onDrop(ok)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
role="button"
|
|
85
|
+
tabIndex={blocked ? -1 : 0}
|
|
86
|
+
aria-disabled={blocked || undefined}
|
|
87
|
+
aria-busy={loading || undefined}
|
|
88
|
+
data-slot="dropzone"
|
|
89
|
+
data-dragging={dragging || undefined}
|
|
90
|
+
onClick={() => !blocked && inputRef.current?.click()}
|
|
91
|
+
onKeyDown={e => {
|
|
92
|
+
if (blocked) return
|
|
93
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click() }
|
|
94
|
+
}}
|
|
95
|
+
onDragOver={e => { e.preventDefault(); if (!blocked) setDragging(true) }}
|
|
96
|
+
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false) }}
|
|
97
|
+
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files) }}
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border px-6 py-8 text-center transition-colors outline-none hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring/50 data-dragging:border-primary data-dragging:bg-primary/5 aria-disabled:cursor-not-allowed aria-disabled:opacity-60 aria-disabled:hover:border-border aria-disabled:hover:bg-transparent",
|
|
100
|
+
className
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<input
|
|
104
|
+
ref={inputRef}
|
|
105
|
+
type="file"
|
|
106
|
+
accept={accept}
|
|
107
|
+
multiple={multiple}
|
|
108
|
+
className="hidden"
|
|
109
|
+
onChange={e => { handleFiles(e.target.files); e.target.value = "" }}
|
|
110
|
+
/>
|
|
111
|
+
{children ?? (
|
|
112
|
+
<>
|
|
113
|
+
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-5">
|
|
114
|
+
{icon ?? <UploadIcon />}
|
|
115
|
+
</span>
|
|
116
|
+
<p className="text-sm font-medium text-foreground">{title}</p>
|
|
117
|
+
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { Dropzone }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Estado vazio: ícone, título, explicação e uma ação. Para listas sem itens,
|
|
7
|
+
* buscas sem resultado e telas que ainda não têm conteúdo.
|
|
8
|
+
*
|
|
9
|
+
* <EmptyState
|
|
10
|
+
* icon={<FileText />}
|
|
11
|
+
* title="Nenhum documento ainda"
|
|
12
|
+
* description="Crie um documento ou importe uma apresentação para começar."
|
|
13
|
+
* action={<Button><Plus /> Novo documento</Button>}
|
|
14
|
+
* />
|
|
15
|
+
*
|
|
16
|
+
* `bordered` desenha a moldura tracejada, para quando o vazio ocupa o lugar de
|
|
17
|
+
* uma área (lista, quadro) e precisa mostrar o contorno dela.
|
|
18
|
+
*/
|
|
19
|
+
function EmptyState({
|
|
20
|
+
icon,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
action,
|
|
24
|
+
bordered = false,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: Omit<React.ComponentProps<"div">, "title"> & {
|
|
28
|
+
icon?: React.ReactNode
|
|
29
|
+
title: React.ReactNode
|
|
30
|
+
description?: React.ReactNode
|
|
31
|
+
action?: React.ReactNode
|
|
32
|
+
bordered?: boolean
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
data-slot="empty-state"
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex flex-col items-center justify-center gap-2 px-6 py-12 text-center",
|
|
39
|
+
bordered && "rounded-lg border-2 border-dashed border-border",
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{icon && (
|
|
45
|
+
<span className="mb-1 flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-6">
|
|
46
|
+
{icon}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
<p className="text-base font-semibold text-foreground">{title}</p>
|
|
50
|
+
{description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
|
|
51
|
+
{action && <div className="mt-3 flex flex-wrap items-center justify-center gap-2">{action}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { EmptyState }
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
import { Badge } from "./badge.js"
|
|
8
|
+
import { useFieldControl } from "./field.js"
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js"
|
|
10
|
+
|
|
11
|
+
export type MultiSelectOption = {
|
|
12
|
+
value: string
|
|
13
|
+
label: string
|
|
14
|
+
/** Agrupa as opções sob um título (ex.: o eixo de uma ação). */
|
|
15
|
+
group?: string
|
|
16
|
+
/** Bolinha de cor antes do rótulo. */
|
|
17
|
+
color?: string
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Seleção de vários itens com busca, como o MultiSelect do Mantine.
|
|
23
|
+
*
|
|
24
|
+
* <MultiSelect
|
|
25
|
+
* options={eixos.map(e => ({ value: e.id, label: e.name, color: e.color }))}
|
|
26
|
+
* value={selecionados}
|
|
27
|
+
* onValueChange={setSelecionados}
|
|
28
|
+
* placeholder="Todos os eixos"
|
|
29
|
+
* />
|
|
30
|
+
*
|
|
31
|
+
* Teclado: digite para filtrar, ↑/↓ para andar, Enter para marcar/desmarcar,
|
|
32
|
+
* Esc para fechar. Dentro de um <Field>, o gatilho recebe o id e os aria-*.
|
|
33
|
+
*/
|
|
34
|
+
function MultiSelect({
|
|
35
|
+
options,
|
|
36
|
+
value,
|
|
37
|
+
onValueChange,
|
|
38
|
+
placeholder = "Selecione",
|
|
39
|
+
searchPlaceholder = "Buscar…",
|
|
40
|
+
emptyText = "Nada encontrado.",
|
|
41
|
+
maxDisplayed = 2,
|
|
42
|
+
searchable = true,
|
|
43
|
+
size = "default",
|
|
44
|
+
disabled = false,
|
|
45
|
+
className,
|
|
46
|
+
contentClassName,
|
|
47
|
+
...props
|
|
48
|
+
}: {
|
|
49
|
+
options: MultiSelectOption[]
|
|
50
|
+
value: string[]
|
|
51
|
+
onValueChange: (value: string[]) => void
|
|
52
|
+
placeholder?: string
|
|
53
|
+
searchPlaceholder?: string
|
|
54
|
+
emptyText?: string
|
|
55
|
+
/** Quantos selecionados aparecem como selo antes de virar "N selecionados". */
|
|
56
|
+
maxDisplayed?: number
|
|
57
|
+
searchable?: boolean
|
|
58
|
+
size?: "sm" | "default"
|
|
59
|
+
disabled?: boolean
|
|
60
|
+
className?: string
|
|
61
|
+
contentClassName?: string
|
|
62
|
+
id?: string
|
|
63
|
+
"aria-label"?: string
|
|
64
|
+
"aria-describedby"?: string
|
|
65
|
+
"aria-invalid"?: React.AriaAttributes["aria-invalid"]
|
|
66
|
+
}) {
|
|
67
|
+
const controlProps = useFieldControl(props, { withRequired: false })
|
|
68
|
+
const [open, setOpen] = React.useState(false)
|
|
69
|
+
const [query, setQuery] = React.useState("")
|
|
70
|
+
const [active, setActive] = React.useState(0)
|
|
71
|
+
const listId = React.useId()
|
|
72
|
+
|
|
73
|
+
const selected = new Set(value)
|
|
74
|
+
const q = query.trim().toLowerCase()
|
|
75
|
+
const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options
|
|
76
|
+
const selectable = filtered.filter(o => !o.disabled)
|
|
77
|
+
|
|
78
|
+
function toggle(v: string) {
|
|
79
|
+
onValueChange(selected.has(v) ? value.filter(x => x !== v) : [...value, v])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onKeyDown(e: React.KeyboardEvent) {
|
|
83
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive(i => Math.min(i + 1, selectable.length - 1)) }
|
|
84
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(i => Math.max(i - 1, 0)) }
|
|
85
|
+
else if (e.key === "Enter") { e.preventDefault(); const o = selectable[active]; if (o) toggle(o.value) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Grupos na ordem em que aparecem nas opções.
|
|
89
|
+
const groups: { name: string | undefined; items: MultiSelectOption[] }[] = []
|
|
90
|
+
for (const o of filtered) {
|
|
91
|
+
const g = groups.find(x => x.name === o.group)
|
|
92
|
+
if (g) g.items.push(o)
|
|
93
|
+
else groups.push({ name: o.group, items: [o] })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const selectedOptions = options.filter(o => selected.has(o.value))
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Popover open={open} onOpenChange={o => { setOpen(o); if (!o) { setQuery(""); setActive(0) } }}>
|
|
100
|
+
<PopoverTrigger asChild disabled={disabled}>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
role="combobox"
|
|
104
|
+
aria-expanded={open}
|
|
105
|
+
aria-controls={listId}
|
|
106
|
+
data-slot="multi-select-trigger"
|
|
107
|
+
data-size={size}
|
|
108
|
+
{...controlProps}
|
|
109
|
+
className={cn(
|
|
110
|
+
"flex w-full min-w-0 cursor-pointer items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent pr-2.5 pl-3 text-left text-sm transition-colors outline-none focus-visible:border-ring disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive data-[size=default]:h-9 data-[size=sm]:h-7.5 data-[state=open]:border-ring dark:bg-input/30",
|
|
111
|
+
className
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
|
115
|
+
{selectedOptions.length === 0 ? (
|
|
116
|
+
<span className="truncate text-muted-foreground">{placeholder}</span>
|
|
117
|
+
) : selectedOptions.length <= maxDisplayed ? (
|
|
118
|
+
selectedOptions.map(o => (
|
|
119
|
+
<Badge key={o.value} variant="secondary" className="max-w-[10rem] normal-case">
|
|
120
|
+
<span className="truncate">{o.label}</span>
|
|
121
|
+
</Badge>
|
|
122
|
+
))
|
|
123
|
+
) : (
|
|
124
|
+
<span className="truncate">{selectedOptions.length} selecionados</span>
|
|
125
|
+
)}
|
|
126
|
+
</span>
|
|
127
|
+
<ChevronDownIcon className="size-4 shrink-0 opacity-70" />
|
|
128
|
+
</button>
|
|
129
|
+
</PopoverTrigger>
|
|
130
|
+
|
|
131
|
+
<PopoverContent
|
|
132
|
+
className={cn("w-(--radix-popover-trigger-width) min-w-60 p-0", contentClassName)}
|
|
133
|
+
onKeyDown={onKeyDown}
|
|
134
|
+
>
|
|
135
|
+
{searchable && (
|
|
136
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
137
|
+
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
138
|
+
<input
|
|
139
|
+
autoFocus
|
|
140
|
+
value={query}
|
|
141
|
+
onChange={e => { setQuery(e.target.value); setActive(0) }}
|
|
142
|
+
placeholder={searchPlaceholder}
|
|
143
|
+
aria-label={searchPlaceholder}
|
|
144
|
+
aria-controls={listId}
|
|
145
|
+
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<div id={listId} role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto overscroll-contain p-1">
|
|
151
|
+
{filtered.length === 0 ? (
|
|
152
|
+
<p className="px-2.5 py-6 text-center text-sm text-muted-foreground">{emptyText}</p>
|
|
153
|
+
) : (
|
|
154
|
+
groups.map(g => (
|
|
155
|
+
<div key={g.name ?? "_"} role="group" aria-label={g.name}>
|
|
156
|
+
{g.name && <p className="px-2.5 pt-2 pb-1 text-xs font-medium text-muted-foreground">{g.name}</p>}
|
|
157
|
+
{g.items.map(o => {
|
|
158
|
+
const isSelected = selected.has(o.value)
|
|
159
|
+
const isActive = selectable[active]?.value === o.value
|
|
160
|
+
return (
|
|
161
|
+
<div
|
|
162
|
+
key={o.value}
|
|
163
|
+
role="option"
|
|
164
|
+
aria-selected={isSelected}
|
|
165
|
+
aria-disabled={o.disabled || undefined}
|
|
166
|
+
data-active={isActive || undefined}
|
|
167
|
+
onMouseEnter={() => { const i = selectable.indexOf(o); if (i >= 0) setActive(i) }}
|
|
168
|
+
onClick={() => !o.disabled && toggle(o.value)}
|
|
169
|
+
className="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm select-none data-active:bg-accent aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
|
170
|
+
>
|
|
171
|
+
<span
|
|
172
|
+
className={cn(
|
|
173
|
+
"flex size-4 shrink-0 items-center justify-center rounded border",
|
|
174
|
+
isSelected ? "border-primary bg-primary text-primary-foreground" : "border-input"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{isSelected && <CheckIcon className="size-3 stroke-3" />}
|
|
178
|
+
</span>
|
|
179
|
+
{o.color && <span className="size-2 shrink-0 rounded-full" style={{ background: o.color }} />}
|
|
180
|
+
<span className="truncate">{o.label}</span>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
))
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{value.length > 0 && (
|
|
190
|
+
<div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
|
191
|
+
<span>{value.length} selecionado{value.length === 1 ? "" : "s"}</span>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={() => onValueChange([])}
|
|
195
|
+
className="cursor-pointer rounded font-medium text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
196
|
+
>
|
|
197
|
+
Limpar
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</PopoverContent>
|
|
202
|
+
</Popover>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { MultiSelect }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cabeçalho de página: título, descrição e ações à direita. É a abertura de
|
|
7
|
+
* toda tela de produto — o header do app mostra só o caminho (breadcrumb),
|
|
8
|
+
* então o título e a explicação da página vivem aqui.
|
|
9
|
+
*
|
|
10
|
+
* <PageHeader
|
|
11
|
+
* title="Painel Executivo"
|
|
12
|
+
* description="Arquivos da empresa compartilhados com a diretoria."
|
|
13
|
+
* actions={<Button>Enviar arquivo</Button>}
|
|
14
|
+
* />
|
|
15
|
+
*
|
|
16
|
+
* No celular as ações descem para baixo do título.
|
|
17
|
+
*/
|
|
18
|
+
function PageHeader({
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
actions,
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: Omit<React.ComponentProps<"div">, "title"> & {
|
|
25
|
+
title: React.ReactNode
|
|
26
|
+
description?: React.ReactNode
|
|
27
|
+
actions?: React.ReactNode
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
data-slot="page-header"
|
|
32
|
+
className={cn("flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6", className)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<div className="min-w-0">
|
|
36
|
+
<h1 className="text-xl leading-tight font-semibold text-foreground sm:text-2xl">{title}</h1>
|
|
37
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
38
|
+
</div>
|
|
39
|
+
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { PageHeader }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
|
|
8
|
+
/** Painel flutuante ancorado num gatilho (Radix Popover), com a mesma
|
|
9
|
+
* superfície dos menus e do Select. */
|
|
10
|
+
function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
11
|
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
15
|
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function PopoverAnchor(props: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
19
|
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function PopoverContent({
|
|
23
|
+
className,
|
|
24
|
+
align = "start",
|
|
25
|
+
sideOffset = 4,
|
|
26
|
+
...props
|
|
27
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
28
|
+
return (
|
|
29
|
+
<PopoverPrimitive.Portal>
|
|
30
|
+
<PopoverPrimitive.Content
|
|
31
|
+
data-slot="popover-content"
|
|
32
|
+
align={align}
|
|
33
|
+
sideOffset={sideOffset}
|
|
34
|
+
className={cn(
|
|
35
|
+
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-3 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
36
|
+
className
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
</PopoverPrimitive.Portal>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Progress as ProgressPrimitive } from "radix-ui"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
|
|
8
|
+
const tones = {
|
|
9
|
+
default: "bg-primary",
|
|
10
|
+
success: "bg-success",
|
|
11
|
+
warning: "bg-warning",
|
|
12
|
+
destructive: "bg-destructive",
|
|
13
|
+
info: "bg-info",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Barra de progresso (0–100), como o Progress do Mantine. */
|
|
17
|
+
function Progress({
|
|
18
|
+
value,
|
|
19
|
+
tone = "default",
|
|
20
|
+
size = "default",
|
|
21
|
+
className,
|
|
22
|
+
...props
|
|
23
|
+
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "value"> & {
|
|
24
|
+
/** 0 a 100. Valores fora da faixa são limitados. */
|
|
25
|
+
value: number
|
|
26
|
+
tone?: keyof typeof tones
|
|
27
|
+
size?: "sm" | "default" | "lg"
|
|
28
|
+
}) {
|
|
29
|
+
const pct = Math.max(0, Math.min(100, value))
|
|
30
|
+
return (
|
|
31
|
+
<ProgressPrimitive.Root
|
|
32
|
+
data-slot="progress"
|
|
33
|
+
value={pct}
|
|
34
|
+
className={cn(
|
|
35
|
+
"relative w-full overflow-hidden rounded-full bg-muted",
|
|
36
|
+
size === "sm" ? "h-1" : size === "lg" ? "h-3" : "h-2",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<ProgressPrimitive.Indicator
|
|
42
|
+
data-slot="progress-indicator"
|
|
43
|
+
className={cn("h-full rounded-full transition-[width] duration-300", tones[tone])}
|
|
44
|
+
style={{ width: `${pct}%` }}
|
|
45
|
+
/>
|
|
46
|
+
</ProgressPrimitive.Root>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { Progress }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
|
|
8
|
+
export type SegmentedControlItem = {
|
|
9
|
+
value: string
|
|
10
|
+
label: React.ReactNode
|
|
11
|
+
/** Ícone antes do rótulo. */
|
|
12
|
+
icon?: React.ReactNode
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
/** Rótulo acessível quando `label` não é texto. */
|
|
15
|
+
"aria-label"?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escolha exclusiva entre poucas opções, como o SegmentedControl do Mantine:
|
|
20
|
+
* trilho cinza com a opção ativa destacada. Para trocar o MODO de ver o mesmo
|
|
21
|
+
* conteúdo (Lista | Quadro, 30d | 60d | 90d). Para navegar entre conteúdos
|
|
22
|
+
* diferentes, use Tabs.
|
|
23
|
+
*
|
|
24
|
+
* <SegmentedControl
|
|
25
|
+
* value={view}
|
|
26
|
+
* onValueChange={setView}
|
|
27
|
+
* data={[{ value: "lista", label: "Lista" }, { value: "quadro", label: "Quadro" }]}
|
|
28
|
+
* />
|
|
29
|
+
*
|
|
30
|
+
* Sempre há uma opção ativa: clicar na ativa não a desmarca.
|
|
31
|
+
*/
|
|
32
|
+
function SegmentedControl({
|
|
33
|
+
data,
|
|
34
|
+
value,
|
|
35
|
+
onValueChange,
|
|
36
|
+
size = "default",
|
|
37
|
+
fullWidth = false,
|
|
38
|
+
disabled,
|
|
39
|
+
className,
|
|
40
|
+
"aria-label": ariaLabel,
|
|
41
|
+
}: {
|
|
42
|
+
data: SegmentedControlItem[]
|
|
43
|
+
value: string
|
|
44
|
+
onValueChange: (value: string) => void
|
|
45
|
+
size?: "sm" | "default"
|
|
46
|
+
/** Ocupa a largura disponível, com as opções em partes iguais. */
|
|
47
|
+
fullWidth?: boolean
|
|
48
|
+
disabled?: boolean
|
|
49
|
+
className?: string
|
|
50
|
+
"aria-label"?: string
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<ToggleGroupPrimitive.Root
|
|
54
|
+
type="single"
|
|
55
|
+
value={value}
|
|
56
|
+
// O Radix desmarca ao clicar na opção ativa (valor vazio): ignora.
|
|
57
|
+
onValueChange={v => { if (v) onValueChange(v) }}
|
|
58
|
+
disabled={disabled}
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
data-slot="segmented-control"
|
|
61
|
+
className={cn(
|
|
62
|
+
"inline-flex items-center gap-0.5 rounded-lg bg-muted p-0.5 text-muted-foreground",
|
|
63
|
+
size === "sm" ? "h-7.5" : "h-9",
|
|
64
|
+
fullWidth ? "flex w-full" : "w-fit",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{data.map(item => (
|
|
69
|
+
<ToggleGroupPrimitive.Item
|
|
70
|
+
key={item.value}
|
|
71
|
+
value={item.value}
|
|
72
|
+
disabled={item.disabled}
|
|
73
|
+
aria-label={item["aria-label"]}
|
|
74
|
+
data-slot="segmented-control-item"
|
|
75
|
+
className={cn(
|
|
76
|
+
"inline-flex h-full cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 font-medium whitespace-nowrap transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:data-[state=on]:bg-input/60 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
77
|
+
size === "sm" ? "text-xs" : "text-sm",
|
|
78
|
+
fullWidth && "flex-1"
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{item.icon}
|
|
82
|
+
{item.label}
|
|
83
|
+
</ToggleGroupPrimitive.Item>
|
|
84
|
+
))}
|
|
85
|
+
</ToggleGroupPrimitive.Root>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { SegmentedControl }
|