@softize/opus 12.5.5 → 12.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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/ui/components/patterns/content-header.tsx +88 -0
- package/src/ui/components/patterns/data-state.tsx +27 -3
- package/src/ui/components/patterns/form.tsx +61 -45
- package/src/ui/components/patterns/list.tsx +238 -231
- package/src/ui/components/patterns/page.tsx +14 -15
- package/src/ui/components/patterns/trigger.tsx +4 -3
- package/src/ui/components/primitives/field.tsx +5 -5
- package/src/ui/components/primitives/icon-picker.tsx +6 -0
- package/src/ui/components/primitives/item.tsx +46 -8
- package/src/ui/components/primitives/select.tsx +7 -0
- package/src/ui/docs/content/action-list.md +20 -1
- package/src/ui/docs/content/item.md +4 -3
- package/src/ui/meta.ts +7 -1
- package/src/ui/react.tsx +10 -2
|
@@ -58,10 +58,10 @@ import { Separator } from '../primitives/separator.tsx'
|
|
|
58
58
|
import { Label } from '../primitives/label.tsx'
|
|
59
59
|
import { Search } from 'lucide-react'
|
|
60
60
|
import { Select, type SelectOption } from '../primitives/select.tsx'
|
|
61
|
-
import { Skeleton } from '../primitives/skeleton.tsx'
|
|
62
61
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table.tsx'
|
|
63
62
|
import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
|
|
64
63
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
64
|
+
import { DataState } from './data-state.tsx'
|
|
65
65
|
|
|
66
66
|
// =============================================================================
|
|
67
67
|
// Helpers
|
|
@@ -165,6 +165,26 @@ export interface ListActionLike {
|
|
|
165
165
|
periods?: PeriodSpec[] | undefined
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** Estado compartilhado pelos filtros declarativos, independente da listagem. */
|
|
169
|
+
export interface ActionFilterState {
|
|
170
|
+
q: string
|
|
171
|
+
filters: Record<string, unknown>
|
|
172
|
+
period: string | null
|
|
173
|
+
from: string | null
|
|
174
|
+
to: string | null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ActionFilterBarProps {
|
|
178
|
+
action: Pick<ListActionLike, 'filters' | 'text' | 'periods'>
|
|
179
|
+
state: ActionFilterState
|
|
180
|
+
onStateChange: (next: ActionFilterState) => void
|
|
181
|
+
filterOptions?: Record<string, SelectOption[]>
|
|
182
|
+
onRefresh?: () => Promise<void> | void
|
|
183
|
+
refreshing?: boolean
|
|
184
|
+
/** Ações de apresentação do consumidor, renderizadas depois do refresh. */
|
|
185
|
+
actions?: ReactNode
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
/** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
|
|
169
189
|
* apresentação é de quem chama; o pattern dá o segment, o estado e os dados. */
|
|
170
190
|
export interface ActionListView<TItem> {
|
|
@@ -684,6 +704,193 @@ function AdvancedFiltersDialog({
|
|
|
684
704
|
)
|
|
685
705
|
}
|
|
686
706
|
|
|
707
|
+
/** Barra declarativa compartilhada por ActionList, relatórios e outras superfícies. */
|
|
708
|
+
export function ActionFilterBar({
|
|
709
|
+
action,
|
|
710
|
+
state,
|
|
711
|
+
onStateChange,
|
|
712
|
+
filterOptions,
|
|
713
|
+
onRefresh,
|
|
714
|
+
refreshing = false,
|
|
715
|
+
actions,
|
|
716
|
+
}: ActionFilterBarProps): React.ReactElement {
|
|
717
|
+
const dicts = useDicts()
|
|
718
|
+
const filterSpecs = Object.entries(action.filters ?? {})
|
|
719
|
+
const inlineSpecs = filterSpecs.filter(([, spec]) => spec.advanced !== true)
|
|
720
|
+
const hasSearch = (action.text?.fields.length ?? 0) > 0
|
|
721
|
+
const periods = action.periods ?? []
|
|
722
|
+
const defaultPeriod = periods.find((period) => period.default === true)?.value ?? periods[0]?.value ?? null
|
|
723
|
+
const activePeriod = state.period ?? defaultPeriod
|
|
724
|
+
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
725
|
+
const toolbarRowRef = useRef<HTMLDivElement | null>(null)
|
|
726
|
+
const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
|
|
727
|
+
|
|
728
|
+
const cascadeClear = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
|
|
729
|
+
for (const [name, spec] of filterSpecs) {
|
|
730
|
+
if (spec.depends?.includes(changed) === true && !isEmptyValue(next[name])) {
|
|
731
|
+
delete next[name]
|
|
732
|
+
cascadeClear(name, next)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return next
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
useLayoutEffect(() => {
|
|
739
|
+
const row = toolbarRowRef.current
|
|
740
|
+
if (row === null) return
|
|
741
|
+
const overflowing = (): boolean =>
|
|
742
|
+
row.scrollWidth > row.clientWidth + 1 ||
|
|
743
|
+
Array.from(row.children).some((child) => child.scrollWidth > child.clientWidth + 1)
|
|
744
|
+
const compute = (): void => {
|
|
745
|
+
row.classList.remove('flex-wrap')
|
|
746
|
+
const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'))
|
|
747
|
+
for (const node of nodes) node.style.removeProperty('display')
|
|
748
|
+
let count = nodes.length
|
|
749
|
+
while (count > 0 && overflowing()) {
|
|
750
|
+
count -= 1
|
|
751
|
+
nodes[count]?.style.setProperty('display', 'none')
|
|
752
|
+
}
|
|
753
|
+
if (count === 0 && overflowing()) row.classList.add('flex-wrap')
|
|
754
|
+
setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
|
|
755
|
+
}
|
|
756
|
+
compute()
|
|
757
|
+
const observer = new ResizeObserver(compute)
|
|
758
|
+
observer.observe(row)
|
|
759
|
+
return () => observer.disconnect()
|
|
760
|
+
}, [inlineSpecs.length, state.filters, inlineFit])
|
|
761
|
+
|
|
762
|
+
const overflowSpecs = Number.isFinite(inlineFit) ? inlineSpecs.slice(inlineFit) : []
|
|
763
|
+
const modalSpecs = filterSpecs.filter(
|
|
764
|
+
([name, spec]) => spec.advanced === true || overflowSpecs.some(([overflowName]) => overflowName === name),
|
|
765
|
+
)
|
|
766
|
+
const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
|
|
767
|
+
const setFilter = (name: string, value: unknown): void => {
|
|
768
|
+
onStateChange({
|
|
769
|
+
...state,
|
|
770
|
+
filters: cascadeClear(name, { ...state.filters, [name]: value }),
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
const chips = modalSpecs
|
|
774
|
+
.filter(([name]) => !isEmptyValue(state.filters[name]))
|
|
775
|
+
.map(([name, spec]) => {
|
|
776
|
+
const raw = state.filters[name]
|
|
777
|
+
const fromProps = optionsFor(name, spec, filterOptions)
|
|
778
|
+
const opts = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
|
|
779
|
+
const display = (value: unknown): string => opts.find((option) => option.value === value)?.label ?? String(value)
|
|
780
|
+
return {
|
|
781
|
+
name,
|
|
782
|
+
label: text(spec.label, name),
|
|
783
|
+
value: Array.isArray(raw) ? raw.map(display).join(', ') : display(raw),
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
|
|
787
|
+
const searchBox = hasSearch ? (
|
|
788
|
+
<ClearableInput
|
|
789
|
+
className="w-64 min-w-36"
|
|
790
|
+
leading={<Search />}
|
|
791
|
+
applied={state.q}
|
|
792
|
+
placeholder={text(action.text?.placeholder, 'Buscar…')}
|
|
793
|
+
onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
|
|
794
|
+
/>
|
|
795
|
+
) : null
|
|
796
|
+
|
|
797
|
+
return (
|
|
798
|
+
<TooltipProvider>
|
|
799
|
+
<div data-slot="action-filter-bar" className="space-y-2">
|
|
800
|
+
<div ref={toolbarRowRef} className="flex items-end gap-3">
|
|
801
|
+
{searchFirst && searchBox}
|
|
802
|
+
{periods.length > 0 && (
|
|
803
|
+
<Labeled label="Período">
|
|
804
|
+
<PeriodControl
|
|
805
|
+
periods={periods}
|
|
806
|
+
period={activePeriod}
|
|
807
|
+
from={state.from}
|
|
808
|
+
to={state.to}
|
|
809
|
+
onChange={(next) => onStateChange({
|
|
810
|
+
...state,
|
|
811
|
+
...next,
|
|
812
|
+
period: next.period === defaultPeriod ? null : next.period,
|
|
813
|
+
})}
|
|
814
|
+
/>
|
|
815
|
+
</Labeled>
|
|
816
|
+
)}
|
|
817
|
+
{inlineSpecs.map(([name, spec]) => (
|
|
818
|
+
<Labeled key={name} label={text(spec.label, name)} slot="action-filter-inline">
|
|
819
|
+
<FilterField
|
|
820
|
+
name={name}
|
|
821
|
+
spec={spec}
|
|
822
|
+
value={state.filters[name]}
|
|
823
|
+
allValues={state.filters}
|
|
824
|
+
onChange={(value) => setFilter(name, value)}
|
|
825
|
+
options={optionsFor(name, spec, filterOptions)}
|
|
826
|
+
/>
|
|
827
|
+
</Labeled>
|
|
828
|
+
))}
|
|
829
|
+
{modalSpecs.length > 0 && (
|
|
830
|
+
<Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
|
|
831
|
+
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
832
|
+
Filtros
|
|
833
|
+
{modalActive > 0 && <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">{modalActive}</Badge>}
|
|
834
|
+
</Button>
|
|
835
|
+
)}
|
|
836
|
+
<div className="ml-auto flex min-w-0 items-center gap-2">
|
|
837
|
+
{!searchFirst && searchBox}
|
|
838
|
+
{onRefresh !== undefined && (
|
|
839
|
+
<Tooltip>
|
|
840
|
+
<TooltipTrigger asChild>
|
|
841
|
+
<Button
|
|
842
|
+
variant="outline"
|
|
843
|
+
className="shrink-0"
|
|
844
|
+
aria-label="Recarregar"
|
|
845
|
+
onClick={() => void onRefresh()}
|
|
846
|
+
disabled={refreshing}
|
|
847
|
+
>
|
|
848
|
+
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
|
|
849
|
+
</Button>
|
|
850
|
+
</TooltipTrigger>
|
|
851
|
+
<TooltipContent>Recarregar</TooltipContent>
|
|
852
|
+
</Tooltip>
|
|
853
|
+
)}
|
|
854
|
+
{actions}
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
{chips.length > 0 && (
|
|
858
|
+
<div data-slot="action-filter-chips" className="flex flex-wrap items-center gap-1.5">
|
|
859
|
+
{chips.map((chip) => (
|
|
860
|
+
<Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
|
|
861
|
+
<span className="text-muted-foreground">{chip.label}:</span> {chip.value}
|
|
862
|
+
<button
|
|
863
|
+
type="button"
|
|
864
|
+
aria-label={`Remover filtro ${chip.label}`}
|
|
865
|
+
onClick={() => {
|
|
866
|
+
const next = { ...state.filters }
|
|
867
|
+
delete next[chip.name]
|
|
868
|
+
onStateChange({ ...state, filters: next })
|
|
869
|
+
}}
|
|
870
|
+
className="rounded-sm text-muted-foreground hover:text-foreground"
|
|
871
|
+
>
|
|
872
|
+
<X className="size-3" />
|
|
873
|
+
</button>
|
|
874
|
+
</Badge>
|
|
875
|
+
))}
|
|
876
|
+
</div>
|
|
877
|
+
)}
|
|
878
|
+
{modalSpecs.length > 0 && (
|
|
879
|
+
<AdvancedFiltersDialog
|
|
880
|
+
open={advancedOpen}
|
|
881
|
+
onOpenChange={setAdvancedOpen}
|
|
882
|
+
specs={modalSpecs}
|
|
883
|
+
values={state.filters}
|
|
884
|
+
cascade={cascadeClear}
|
|
885
|
+
onApply={(filters) => onStateChange({ ...state, filters })}
|
|
886
|
+
filterOptions={filterOptions}
|
|
887
|
+
/>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
890
|
+
</TooltipProvider>
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
|
|
687
894
|
// =============================================================================
|
|
688
895
|
// ActionList
|
|
689
896
|
// =============================================================================
|
|
@@ -719,8 +926,6 @@ export function ActionList<TInput, TItem>({
|
|
|
719
926
|
// — Estado da toolbar (interno ou controlado) —
|
|
720
927
|
const defaultSort = action.sort?.default?.[0] ?? null
|
|
721
928
|
const [internal, setInternal] = useState<ActionListState>({ ...emptyListState(), sort: defaultSort })
|
|
722
|
-
// Dicts do provider — labels de filtro dictionary nos CHIPS (o FilterField resolve os seus).
|
|
723
|
-
const dicts = useDicts()
|
|
724
929
|
// Normaliza estado externo montado à mão (chaves novas ausentes = null/default).
|
|
725
930
|
const raw = controlledState ?? internal
|
|
726
931
|
const state: ActionListState = {
|
|
@@ -741,64 +946,7 @@ export function ActionList<TInput, TItem>({
|
|
|
741
946
|
const setStateResetPage = (next: ActionListState): void => setState({ ...next, page: 1 })
|
|
742
947
|
|
|
743
948
|
const filterSpecs = Object.entries(action.filters ?? {})
|
|
744
|
-
// Mudou um filtro → limpa (em cascata) todo filtro que declara `depends` nele:
|
|
745
|
-
// o recorte dependente perde o sentido quando o pai muda.
|
|
746
|
-
const cascadeClear = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
|
|
747
|
-
for (const [n, sp] of filterSpecs) {
|
|
748
|
-
if (sp.depends?.includes(changed) === true && !isEmptyValue(next[n])) {
|
|
749
|
-
delete next[n]
|
|
750
|
-
cascadeClear(n, next)
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
return next
|
|
754
|
-
}
|
|
755
|
-
const inlineSpecs = filterSpecs.filter(([, s]) => s.advanced !== true)
|
|
756
949
|
const hasSearch = (action.text?.fields.length ?? 0) > 0
|
|
757
|
-
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
758
|
-
|
|
759
|
-
// — Overflow responsivo: filtro inline que NÃO CABE na linha vai pro modal (com os
|
|
760
|
-
// advanced). Medição real: esconde do fim pro começo até a linha parar de
|
|
761
|
-
// transbordar (useLayoutEffect = antes do paint, sem piscar) e re-mede no resize.
|
|
762
|
-
const toolbarRowRef = useRef<HTMLDivElement | null>(null)
|
|
763
|
-
const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
|
|
764
|
-
useLayoutEffect(() => {
|
|
765
|
-
const row = toolbarRowRef.current
|
|
766
|
-
if (row === null) return
|
|
767
|
-
// Transborda de dois jeitos: a linha estoura (scrollWidth) OU um filho flexível
|
|
768
|
-
// (o grupo da direita, min-w-0) encolhe abaixo do conteúdo e vaza por cima dos
|
|
769
|
-
// vizinhos — esse segundo caso não mexe no scrollWidth da linha.
|
|
770
|
-
const overflowing = (): boolean =>
|
|
771
|
-
row.scrollWidth > row.clientWidth + 1 ||
|
|
772
|
-
Array.from(row.children).some((c) => c.scrollWidth > c.clientWidth + 1)
|
|
773
|
-
const compute = (): void => {
|
|
774
|
-
// Medição sempre em linha única; o wrap é o último recurso (re-avaliado abaixo).
|
|
775
|
-
row.classList.remove('flex-wrap')
|
|
776
|
-
const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-list-inline-filter"]'))
|
|
777
|
-
for (const n of nodes) n.style.removeProperty('display')
|
|
778
|
-
let count = nodes.length
|
|
779
|
-
while (count > 0 && overflowing()) {
|
|
780
|
-
count -= 1
|
|
781
|
-
const node = nodes[count]
|
|
782
|
-
if (node !== undefined) node.style.display = 'none'
|
|
783
|
-
}
|
|
784
|
-
// Esgotou (zero filtros na linha) e AINDA não cabe → quebra a linha em vez de
|
|
785
|
-
// recortar controle (busca no mínimo + segment + botões > largura disponível).
|
|
786
|
-
if (count === 0 && overflowing()) row.classList.add('flex-wrap')
|
|
787
|
-
setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
|
|
788
|
-
}
|
|
789
|
-
compute()
|
|
790
|
-
const ro = new ResizeObserver(compute)
|
|
791
|
-
ro.observe(row)
|
|
792
|
-
return () => ro.disconnect()
|
|
793
|
-
// inlineFit na dep: o setInlineFit pode fazer o botão "Filtros" aparecer e
|
|
794
|
-
// mudar a medida — o compute é idempotente, então re-rodar converge (sem loop).
|
|
795
|
-
}, [inlineSpecs.length, state.filters, inlineFit])
|
|
796
|
-
const overflowSpecs = Number.isFinite(inlineFit) ? inlineSpecs.slice(inlineFit) : []
|
|
797
|
-
// O modal recebe os advanced + os inline que transbordaram (ordem do contrato).
|
|
798
|
-
const modalSpecs = filterSpecs.filter(
|
|
799
|
-
([name, s]) => s.advanced === true || overflowSpecs.some(([n]) => n === name),
|
|
800
|
-
)
|
|
801
|
-
const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
|
|
802
950
|
|
|
803
951
|
// — Views: 'table' (quando há colunas) + as nomeadas. O segment só existe com 2+. —
|
|
804
952
|
const viewEntries: Array<[string, string, ReactNode?]> = [
|
|
@@ -808,9 +956,6 @@ export function ActionList<TInput, TItem>({
|
|
|
808
956
|
const activeView = state.view ?? viewEntries[0]?.[0] ?? 'table'
|
|
809
957
|
const hasSegment = children === undefined && viewEntries.length > 1
|
|
810
958
|
const periods = action.periods ?? []
|
|
811
|
-
// Período é recorte obrigatório: o default é o preset marcado (ou o primeiro).
|
|
812
|
-
const defaultPeriod = periods.find((p) => p.default === true)?.value ?? periods[0]?.value ?? null
|
|
813
|
-
const activePeriod = state.period ?? defaultPeriod
|
|
814
959
|
const hasToolbar = hasSearch || filterSpecs.length > 0 || periods.length > 0 || hasSegment || hasTable
|
|
815
960
|
|
|
816
961
|
// — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
|
|
@@ -925,95 +1070,17 @@ export function ActionList<TInput, TItem>({
|
|
|
925
1070
|
}
|
|
926
1071
|
}
|
|
927
1072
|
|
|
928
|
-
// — Chips: só dos filtros DO MODAL (advanced + inline transbordado): os inline
|
|
929
|
-
// visíveis mostram o próprio estado; os do modal ficariam invisíveis sem isto —
|
|
930
|
-
const chips = modalSpecs
|
|
931
|
-
.filter(([name]) => !isEmptyValue(state.filters[name]))
|
|
932
|
-
.map(([name, spec]) => {
|
|
933
|
-
const raw = state.filters[name]
|
|
934
|
-
// Mesma precedência do FilterField: runtime/estáticas > dictionary do provider.
|
|
935
|
-
const fromProps = optionsFor(name, spec, filterOptions)
|
|
936
|
-
const opts = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
|
|
937
|
-
const display = (v: unknown): string => opts.find((o) => o.value === v)?.label ?? String(v)
|
|
938
|
-
const value = Array.isArray(raw) ? raw.map(display).join(', ') : display(raw)
|
|
939
|
-
return { name, label: text(spec.label, name), value }
|
|
940
|
-
})
|
|
941
|
-
const clearFilter = (name: string): void => {
|
|
942
|
-
const next = { ...state.filters }
|
|
943
|
-
delete next[name]
|
|
944
|
-
setStateResetPage({ ...state, filters: next })
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// A busca abre a linha SÓ quando é a única forma de recorte; com período ou
|
|
948
|
-
// filtros no contrato, o período abre e a busca fica no grupo da direita.
|
|
949
|
-
const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
|
|
950
|
-
// Largura AUTO (todos os controles em default h-9, alinham nativamente):
|
|
951
|
-
// 64 com folga, encolhe até 36 no aperto — é o item flexível da linha.
|
|
952
|
-
const searchBox = hasSearch ? (
|
|
953
|
-
<ClearableInput
|
|
954
|
-
className="w-64 min-w-36"
|
|
955
|
-
leading={<Search />}
|
|
956
|
-
applied={state.q}
|
|
957
|
-
placeholder={text(action.text?.placeholder, 'Buscar…')}
|
|
958
|
-
onApply={(v) => {
|
|
959
|
-
if (v !== state.q) setStateResetPage({ ...state, q: v })
|
|
960
|
-
}}
|
|
961
|
-
/>
|
|
962
|
-
) : null
|
|
963
|
-
|
|
964
1073
|
const toolbar = hasToolbar ? (
|
|
965
|
-
<div data-slot="action-list-toolbar"
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
{
|
|
970
|
-
{
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
from={state.from}
|
|
976
|
-
to={state.to}
|
|
977
|
-
onChange={(next) =>
|
|
978
|
-
setStateResetPage({
|
|
979
|
-
...state,
|
|
980
|
-
...next,
|
|
981
|
-
period: next.period === defaultPeriod ? null : next.period,
|
|
982
|
-
})
|
|
983
|
-
}
|
|
984
|
-
/>
|
|
985
|
-
</Labeled>
|
|
986
|
-
)}
|
|
987
|
-
{inlineSpecs.map(([name, spec]) => (
|
|
988
|
-
<Labeled key={name} label={text(spec.label, name)} slot="action-list-inline-filter">
|
|
989
|
-
<FilterField
|
|
990
|
-
name={name}
|
|
991
|
-
spec={spec}
|
|
992
|
-
value={state.filters[name]}
|
|
993
|
-
allValues={state.filters}
|
|
994
|
-
onChange={(v) =>
|
|
995
|
-
setStateResetPage({ ...state, filters: cascadeClear(name, { ...state.filters, [name]: v }) })
|
|
996
|
-
}
|
|
997
|
-
options={optionsFor(name, spec, filterOptions)}
|
|
998
|
-
/>
|
|
999
|
-
</Labeled>
|
|
1000
|
-
))}
|
|
1001
|
-
{modalSpecs.length > 0 && (
|
|
1002
|
-
<Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
|
|
1003
|
-
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
1004
|
-
Filtros
|
|
1005
|
-
{modalActive > 0 && (
|
|
1006
|
-
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
|
1007
|
-
{modalActive}
|
|
1008
|
-
</Badge>
|
|
1009
|
-
)}
|
|
1010
|
-
</Button>
|
|
1011
|
-
)}
|
|
1012
|
-
{/* O grupo da direita cede por min-w-0 (sem justify-end: o vazamento no
|
|
1013
|
-
aperto cai pra DIREITA, onde o scrollWidth da detecção enxerga); a busca
|
|
1014
|
-
aqui dentro é o item flexível — encolhe antes de filtro migrar pro modal. */}
|
|
1015
|
-
<div className="ml-auto flex min-w-0 items-center gap-2">
|
|
1016
|
-
{!searchFirst && searchBox}
|
|
1074
|
+
<div data-slot="action-list-toolbar">
|
|
1075
|
+
<ActionFilterBar
|
|
1076
|
+
action={action}
|
|
1077
|
+
state={state}
|
|
1078
|
+
onStateChange={(next) => setStateResetPage({ ...state, ...next })}
|
|
1079
|
+
filterOptions={filterOptions}
|
|
1080
|
+
onRefresh={() => refetch()}
|
|
1081
|
+
refreshing={isLoading}
|
|
1082
|
+
actions={
|
|
1083
|
+
<>
|
|
1017
1084
|
{hasSegment && (
|
|
1018
1085
|
<ToggleGroup
|
|
1019
1086
|
type="single"
|
|
@@ -1039,20 +1106,6 @@ export function ActionList<TInput, TItem>({
|
|
|
1039
1106
|
))}
|
|
1040
1107
|
</ToggleGroup>
|
|
1041
1108
|
)}
|
|
1042
|
-
<Tooltip>
|
|
1043
|
-
<TooltipTrigger asChild>
|
|
1044
|
-
<Button
|
|
1045
|
-
variant="outline"
|
|
1046
|
-
className="shrink-0"
|
|
1047
|
-
aria-label="Recarregar"
|
|
1048
|
-
onClick={() => void refetch()}
|
|
1049
|
-
disabled={isLoading}
|
|
1050
|
-
>
|
|
1051
|
-
<RefreshCw className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
|
|
1052
|
-
</Button>
|
|
1053
|
-
</TooltipTrigger>
|
|
1054
|
-
<TooltipContent>Recarregar</TooltipContent>
|
|
1055
|
-
</Tooltip>
|
|
1056
1109
|
{/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
|
|
1057
1110
|
tabela está ativa, itens por página sempre, e o que vier depois. */}
|
|
1058
1111
|
<Popover>
|
|
@@ -1119,81 +1172,25 @@ export function ActionList<TInput, TItem>({
|
|
|
1119
1172
|
</div>
|
|
1120
1173
|
</PopoverContent>
|
|
1121
1174
|
</Popover>
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
<div data-slot="action-list-chips" className="flex flex-wrap items-center gap-1.5">
|
|
1126
|
-
{chips.map((chip) => (
|
|
1127
|
-
<Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
|
|
1128
|
-
<span className="text-muted-foreground">{chip.label}:</span> {chip.value}
|
|
1129
|
-
<button
|
|
1130
|
-
type="button"
|
|
1131
|
-
aria-label={`Remover filtro ${chip.label}`}
|
|
1132
|
-
onClick={() => clearFilter(chip.name)}
|
|
1133
|
-
className="rounded-sm text-muted-foreground hover:text-foreground"
|
|
1134
|
-
>
|
|
1135
|
-
<X className="size-3" />
|
|
1136
|
-
</button>
|
|
1137
|
-
</Badge>
|
|
1138
|
-
))}
|
|
1139
|
-
</div>
|
|
1140
|
-
)}
|
|
1141
|
-
{modalSpecs.length > 0 && (
|
|
1142
|
-
<AdvancedFiltersDialog
|
|
1143
|
-
open={advancedOpen}
|
|
1144
|
-
onOpenChange={setAdvancedOpen}
|
|
1145
|
-
specs={modalSpecs}
|
|
1146
|
-
values={state.filters}
|
|
1147
|
-
cascade={cascadeClear}
|
|
1148
|
-
onApply={(next) => setStateResetPage({ ...state, filters: next })}
|
|
1149
|
-
filterOptions={filterOptions}
|
|
1150
|
-
/>
|
|
1151
|
-
)}
|
|
1175
|
+
</>
|
|
1176
|
+
}
|
|
1177
|
+
/>
|
|
1152
1178
|
</div>
|
|
1153
1179
|
) : null
|
|
1154
1180
|
|
|
1155
1181
|
// — Estados de carga —
|
|
1156
1182
|
const busy = isFetching || extraLoading === true
|
|
1157
1183
|
const isEmpty = empty !== undefined ? empty(items) : items.length === 0
|
|
1158
|
-
let
|
|
1159
|
-
if (
|
|
1160
|
-
body = (
|
|
1161
|
-
<div className="space-y-2">
|
|
1162
|
-
<Skeleton className="h-10 w-full" />
|
|
1163
|
-
<Skeleton className="h-10 w-full" />
|
|
1164
|
-
<Skeleton className="h-10 w-full" />
|
|
1165
|
-
</div>
|
|
1166
|
-
)
|
|
1167
|
-
} else if (isError && error !== undefined) {
|
|
1168
|
-
body = (
|
|
1169
|
-
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm space-y-3">
|
|
1170
|
-
<div>
|
|
1171
|
-
<strong className="text-destructive">{error.code}</strong>
|
|
1172
|
-
<p className="text-destructive">{error.message}</p>
|
|
1173
|
-
</div>
|
|
1174
|
-
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
1175
|
-
Tentar de novo
|
|
1176
|
-
</Button>
|
|
1177
|
-
</div>
|
|
1178
|
-
)
|
|
1179
|
-
} else if (isEmpty) {
|
|
1180
|
-
body = (
|
|
1181
|
-
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
1182
|
-
{emptyMessage}
|
|
1183
|
-
</div>
|
|
1184
|
-
)
|
|
1185
|
-
} else if (children !== undefined) {
|
|
1184
|
+
let content: ReactNode
|
|
1185
|
+
if (children !== undefined) {
|
|
1186
1186
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
1187
|
-
|
|
1187
|
+
content = children(items, () => refetch())
|
|
1188
1188
|
} else if (activeView !== 'table' && views?.[activeView] !== undefined) {
|
|
1189
1189
|
// View alternativa (board/galeria/…): mesma fonte e toolbar, outro renderer.
|
|
1190
|
-
|
|
1190
|
+
content = views[activeView].render(items, () => refetch())
|
|
1191
1191
|
} else {
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
// scroll horizontal contido; a última linha fica sem borda pelo TableBody.
|
|
1195
|
-
<div className="overflow-hidden rounded-lg border border-border">
|
|
1196
|
-
<Table>
|
|
1192
|
+
content = (
|
|
1193
|
+
<Table variant="framed">
|
|
1197
1194
|
<TableHeader>
|
|
1198
1195
|
<TableRow>
|
|
1199
1196
|
{hasSelection && (
|
|
@@ -1271,9 +1268,19 @@ export function ActionList<TInput, TItem>({
|
|
|
1271
1268
|
))}
|
|
1272
1269
|
</TableBody>
|
|
1273
1270
|
</Table>
|
|
1274
|
-
</div>
|
|
1275
1271
|
)
|
|
1276
1272
|
}
|
|
1273
|
+
const body = (
|
|
1274
|
+
<DataState
|
|
1275
|
+
loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
|
|
1276
|
+
error={isError ? error : null}
|
|
1277
|
+
empty={isEmpty}
|
|
1278
|
+
emptyText={emptyMessage}
|
|
1279
|
+
onRetry={() => refetch()}
|
|
1280
|
+
>
|
|
1281
|
+
{content}
|
|
1282
|
+
</DataState>
|
|
1283
|
+
)
|
|
1277
1284
|
|
|
1278
1285
|
// — Barra de seleção (batch): aparece só com itens marcados —
|
|
1279
1286
|
const selectionBar =
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ReactNode } from 'react'
|
|
13
13
|
import { cn } from '../../lib/cn.ts'
|
|
14
|
+
import { ContentHeaderFrame } from './content-header.tsx'
|
|
14
15
|
|
|
15
16
|
export interface PageProps {
|
|
16
17
|
title: string
|
|
@@ -29,22 +30,20 @@ export function Page({ title, count, description, actions, className, children }
|
|
|
29
30
|
return (
|
|
30
31
|
<main data-slot="page" className="min-w-0 flex-1">
|
|
31
32
|
<div className={cn('mx-auto px-8 py-8', className)}>
|
|
32
|
-
{/* As ações acompanham o fim do contexto, mesmo quando há uma descrição sob o título. */}
|
|
33
33
|
<div data-slot="page-header" className="mb-6">
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{actions
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</div>
|
|
34
|
+
<ContentHeaderFrame
|
|
35
|
+
title={title}
|
|
36
|
+
meta={
|
|
37
|
+
count === undefined ? undefined : (
|
|
38
|
+
<span className="font-mono text-base text-muted-foreground/60">{count}</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
description={description}
|
|
42
|
+
actions={actions}
|
|
43
|
+
level={1}
|
|
44
|
+
variant="page"
|
|
45
|
+
slots={{ heading: 'page-heading', actions: 'page-actions' }}
|
|
46
|
+
/>
|
|
48
47
|
</div>
|
|
49
48
|
{children}
|
|
50
49
|
</div>
|
|
@@ -123,7 +123,8 @@ export function ActionTrigger<TInput, TData>({
|
|
|
123
123
|
const destructive = spec?.destructive === true
|
|
124
124
|
// Com ícone o botão é chrome do item: fica ghost, e o vermelho aparece no hover — uma
|
|
125
125
|
// lixeira sólida vermelha em cada linha seria um campo minado visual.
|
|
126
|
-
const
|
|
126
|
+
const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : destructive ? 'destructive' : 'default')
|
|
127
|
+
const confirmVariant = destructive ? 'destructive' : (variant ?? 'default')
|
|
127
128
|
|
|
128
129
|
const fire = () => {
|
|
129
130
|
void trigger(input)
|
|
@@ -135,7 +136,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
135
136
|
const renderButton = (onClick: () => void): React.ReactElement => {
|
|
136
137
|
const button = (
|
|
137
138
|
<Button
|
|
138
|
-
variant={
|
|
139
|
+
variant={triggerVariant}
|
|
139
140
|
size={icon !== undefined ? 'icon' : size}
|
|
140
141
|
busy={isLoading}
|
|
141
142
|
disabled={disabled}
|
|
@@ -185,7 +186,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
185
186
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={isLoading}>
|
|
186
187
|
{confirm.cancelLabel ?? 'Cancelar'}
|
|
187
188
|
</Button>
|
|
188
|
-
<Button variant={
|
|
189
|
+
<Button variant={confirmVariant} busy={isLoading} onClick={fire}>
|
|
189
190
|
{confirm.actionLabel ?? buttonLabel}
|
|
190
191
|
</Button>
|
|
191
192
|
</AlertDialogFooter>
|
|
@@ -44,7 +44,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
44
44
|
<div
|
|
45
45
|
data-slot="field-group"
|
|
46
46
|
className={cn(
|
|
47
|
-
"group/field-group @container/field-group flex w-full flex-col gap-
|
|
47
|
+
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
|
48
48
|
className
|
|
49
49
|
)}
|
|
50
50
|
{...props}
|
|
@@ -53,18 +53,18 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const fieldVariants = cva(
|
|
56
|
-
"group/field flex w-full
|
|
56
|
+
"group/field flex w-full data-[invalid=true]:text-destructive",
|
|
57
57
|
{
|
|
58
58
|
variants: {
|
|
59
59
|
orientation: {
|
|
60
|
-
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
|
60
|
+
vertical: ["flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto"],
|
|
61
61
|
horizontal: [
|
|
62
|
-
"flex-row items-center",
|
|
62
|
+
"flex-row items-center gap-3",
|
|
63
63
|
"[&>[data-slot=field-label]]:flex-auto",
|
|
64
64
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
65
65
|
],
|
|
66
66
|
responsive: [
|
|
67
|
-
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
|
67
|
+
"flex-col gap-2 @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
|
68
68
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
|
69
69
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
70
70
|
],
|