@strategicos/ui 0.5.0 → 0.6.1

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.
Files changed (50) hide show
  1. package/README.md +16 -1
  2. package/dist/components/accordion.d.ts +10 -0
  3. package/dist/components/accordion.d.ts.map +1 -0
  4. package/dist/components/accordion.js +21 -0
  5. package/dist/components/accordion.js.map +1 -0
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/button.d.ts +1 -1
  8. package/dist/components/chip.d.ts +40 -0
  9. package/dist/components/chip.d.ts.map +1 -0
  10. package/dist/components/chip.js +23 -0
  11. package/dist/components/chip.js.map +1 -0
  12. package/dist/components/dropzone.d.ts +38 -0
  13. package/dist/components/dropzone.d.ts.map +1 -0
  14. package/dist/components/dropzone.js +67 -0
  15. package/dist/components/dropzone.js.map +1 -0
  16. package/dist/components/empty-state.d.ts +24 -0
  17. package/dist/components/empty-state.d.ts.map +1 -0
  18. package/dist/components/empty-state.js +21 -0
  19. package/dist/components/empty-state.js.map +1 -0
  20. package/dist/components/multi-select.d.ts +47 -0
  21. package/dist/components/multi-select.d.ts.map +1 -0
  22. package/dist/components/multi-select.js +72 -0
  23. package/dist/components/multi-select.js.map +1 -0
  24. package/dist/components/popover.d.ts +10 -0
  25. package/dist/components/popover.d.ts.map +1 -0
  26. package/dist/components/popover.js +20 -0
  27. package/dist/components/popover.js.map +1 -0
  28. package/dist/components/progress.d.ts +18 -0
  29. package/dist/components/progress.d.ts.map +1 -0
  30. package/dist/components/progress.js +18 -0
  31. package/dist/components/progress.js.map +1 -0
  32. package/dist/components/segmented-control.d.ts +37 -0
  33. package/dist/components/segmented-control.d.ts.map +1 -0
  34. package/dist/components/segmented-control.js +26 -0
  35. package/dist/components/segmented-control.js.map +1 -0
  36. package/dist/components/stat.d.ts +27 -0
  37. package/dist/components/stat.d.ts.map +1 -0
  38. package/dist/components/stat.js +23 -0
  39. package/dist/components/stat.js.map +1 -0
  40. package/dist/components/tabs.d.ts +2 -2
  41. package/package.json +1 -1
  42. package/src/components/accordion.tsx +55 -0
  43. package/src/components/chip.tsx +111 -0
  44. package/src/components/dropzone.tsx +124 -0
  45. package/src/components/empty-state.tsx +56 -0
  46. package/src/components/multi-select.tsx +210 -0
  47. package/src/components/popover.tsx +44 -0
  48. package/src/components/progress.tsx +50 -0
  49. package/src/components/segmented-control.tsx +89 -0
  50. package/src/components/stat.tsx +65 -0
@@ -0,0 +1,210 @@
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
+ onOpenChange,
48
+ ...props
49
+ }: {
50
+ options: MultiSelectOption[]
51
+ value: string[]
52
+ onValueChange: (value: string[]) => void
53
+ placeholder?: string
54
+ searchPlaceholder?: string
55
+ emptyText?: string
56
+ /** Quantos selecionados aparecem como selo antes de virar "N selecionados". */
57
+ maxDisplayed?: number
58
+ searchable?: boolean
59
+ size?: "sm" | "default"
60
+ disabled?: boolean
61
+ className?: string
62
+ contentClassName?: string
63
+ /** Avisa quando a lista abre e fecha. Útil para gravar a seleção uma vez só,
64
+ * ao fechar, em vez de a cada clique. */
65
+ onOpenChange?: (open: boolean) => void
66
+ id?: string
67
+ "aria-label"?: string
68
+ "aria-describedby"?: string
69
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"]
70
+ }) {
71
+ const controlProps = useFieldControl(props, { withRequired: false })
72
+ const [open, setOpen] = React.useState(false)
73
+ const [query, setQuery] = React.useState("")
74
+ const [active, setActive] = React.useState(0)
75
+ const listId = React.useId()
76
+
77
+ const selected = new Set(value)
78
+ const q = query.trim().toLowerCase()
79
+ const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options
80
+ const selectable = filtered.filter(o => !o.disabled)
81
+
82
+ function toggle(v: string) {
83
+ onValueChange(selected.has(v) ? value.filter(x => x !== v) : [...value, v])
84
+ }
85
+
86
+ function onKeyDown(e: React.KeyboardEvent) {
87
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive(i => Math.min(i + 1, selectable.length - 1)) }
88
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive(i => Math.max(i - 1, 0)) }
89
+ else if (e.key === "Enter") { e.preventDefault(); const o = selectable[active]; if (o) toggle(o.value) }
90
+ }
91
+
92
+ // Grupos na ordem em que aparecem nas opções.
93
+ const groups: { name: string | undefined; items: MultiSelectOption[] }[] = []
94
+ for (const o of filtered) {
95
+ const g = groups.find(x => x.name === o.group)
96
+ if (g) g.items.push(o)
97
+ else groups.push({ name: o.group, items: [o] })
98
+ }
99
+
100
+ const selectedOptions = options.filter(o => selected.has(o.value))
101
+
102
+ return (
103
+ <Popover open={open} onOpenChange={o => { setOpen(o); if (!o) { setQuery(""); setActive(0) }; onOpenChange?.(o) }}>
104
+ <PopoverTrigger asChild disabled={disabled}>
105
+ <button
106
+ type="button"
107
+ role="combobox"
108
+ aria-expanded={open}
109
+ aria-controls={listId}
110
+ data-slot="multi-select-trigger"
111
+ data-size={size}
112
+ {...controlProps}
113
+ className={cn(
114
+ "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",
115
+ className
116
+ )}
117
+ >
118
+ <span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
119
+ {selectedOptions.length === 0 ? (
120
+ <span className="truncate text-muted-foreground">{placeholder}</span>
121
+ ) : selectedOptions.length <= maxDisplayed ? (
122
+ selectedOptions.map(o => (
123
+ <Badge key={o.value} variant="secondary" className="max-w-[10rem] normal-case">
124
+ <span className="truncate">{o.label}</span>
125
+ </Badge>
126
+ ))
127
+ ) : (
128
+ <span className="truncate">{selectedOptions.length} selecionado{selectedOptions.length === 1 ? "" : "s"}</span>
129
+ )}
130
+ </span>
131
+ <ChevronDownIcon className="size-4 shrink-0 opacity-70" />
132
+ </button>
133
+ </PopoverTrigger>
134
+
135
+ <PopoverContent
136
+ className={cn("w-(--radix-popover-trigger-width) min-w-60 p-0", contentClassName)}
137
+ onKeyDown={onKeyDown}
138
+ >
139
+ {searchable && (
140
+ <div className="flex items-center gap-2 border-b border-border px-3">
141
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
142
+ <input
143
+ autoFocus
144
+ value={query}
145
+ onChange={e => { setQuery(e.target.value); setActive(0) }}
146
+ placeholder={searchPlaceholder}
147
+ aria-label={searchPlaceholder}
148
+ aria-controls={listId}
149
+ className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
150
+ />
151
+ </div>
152
+ )}
153
+
154
+ <div id={listId} role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto overscroll-contain p-1">
155
+ {filtered.length === 0 ? (
156
+ <p className="px-2.5 py-6 text-center text-sm text-muted-foreground">{emptyText}</p>
157
+ ) : (
158
+ groups.map(g => (
159
+ <div key={g.name ?? "_"} role="group" aria-label={g.name}>
160
+ {g.name && <p className="px-2.5 pt-2 pb-1 text-xs font-medium text-muted-foreground">{g.name}</p>}
161
+ {g.items.map(o => {
162
+ const isSelected = selected.has(o.value)
163
+ const isActive = selectable[active]?.value === o.value
164
+ return (
165
+ <div
166
+ key={o.value}
167
+ role="option"
168
+ aria-selected={isSelected}
169
+ aria-disabled={o.disabled || undefined}
170
+ data-active={isActive || undefined}
171
+ onMouseEnter={() => { const i = selectable.indexOf(o); if (i >= 0) setActive(i) }}
172
+ onClick={() => !o.disabled && toggle(o.value)}
173
+ 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"
174
+ >
175
+ <span
176
+ className={cn(
177
+ "flex size-4 shrink-0 items-center justify-center rounded border",
178
+ isSelected ? "border-primary bg-primary text-primary-foreground" : "border-input"
179
+ )}
180
+ >
181
+ {isSelected && <CheckIcon className="size-3 stroke-3" />}
182
+ </span>
183
+ {o.color && <span className="size-2 shrink-0 rounded-full" style={{ background: o.color }} />}
184
+ <span className="truncate">{o.label}</span>
185
+ </div>
186
+ )
187
+ })}
188
+ </div>
189
+ ))
190
+ )}
191
+ </div>
192
+
193
+ {value.length > 0 && (
194
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-muted-foreground">
195
+ <span>{value.length} selecionado{value.length === 1 ? "" : "s"}</span>
196
+ <button
197
+ type="button"
198
+ onClick={() => onValueChange([])}
199
+ className="cursor-pointer rounded font-medium text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
200
+ >
201
+ Limpar
202
+ </button>
203
+ </div>
204
+ )}
205
+ </PopoverContent>
206
+ </Popover>
207
+ )
208
+ }
209
+
210
+ export { MultiSelect }
@@ -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 }
@@ -0,0 +1,65 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/utils.js"
4
+ import { Badge } from "./badge.js"
5
+
6
+ /**
7
+ * Indicador numérico, como os "StatsGrid" do Mantine: rótulo, valor em
8
+ * destaque, contexto e, opcionalmente, um selo de variação e um ícone.
9
+ *
10
+ * <Stat
11
+ * label="Total após a reforma"
12
+ * value="R$ 1.890.000"
13
+ * description="2024 — primeira fase"
14
+ * delta="−R$ 560k"
15
+ * deltaTone="success"
16
+ * icon={<TrendingDown />}
17
+ * />
18
+ *
19
+ * Use num grid (ex.: `grid gap-4 sm:grid-cols-2 xl:grid-cols-4`).
20
+ */
21
+ function Stat({
22
+ label,
23
+ value,
24
+ description,
25
+ delta,
26
+ deltaTone = "secondary",
27
+ icon,
28
+ className,
29
+ ...props
30
+ }: Omit<React.ComponentProps<"div">, "children"> & {
31
+ label: React.ReactNode
32
+ value: React.ReactNode
33
+ description?: React.ReactNode
34
+ /** Variação ou etiqueta curta, num selo. */
35
+ delta?: React.ReactNode
36
+ deltaTone?: "secondary" | "success" | "warning" | "destructive" | "info"
37
+ icon?: React.ReactNode
38
+ }) {
39
+ return (
40
+ <div
41
+ data-slot="stat"
42
+ className={cn("flex flex-col gap-1 rounded-lg border border-border bg-card p-4 shadow-card", className)}
43
+ {...props}
44
+ >
45
+ <div className="flex items-start justify-between gap-3">
46
+ <p className="text-sm text-muted-foreground">{label}</p>
47
+ {icon && (
48
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-4">
49
+ {icon}
50
+ </span>
51
+ )}
52
+ </div>
53
+ <p className="text-2xl leading-tight font-semibold text-foreground tabular-nums">{value}</p>
54
+ {(description || delta) && (
55
+ <div className="mt-1 flex flex-wrap items-center gap-2">
56
+ {/* normal-case: o Badge é em caixa alta, o que distorce valores ("p.p." → "P.P."). */}
57
+ {delta && <Badge variant={deltaTone} className="normal-case">{delta}</Badge>}
58
+ {description && <span className="text-xs text-muted-foreground">{description}</span>}
59
+ </div>
60
+ )}
61
+ </div>
62
+ )
63
+ }
64
+
65
+ export { Stat }