@softize/opus 18.0.1 → 18.1.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 +47 -0
- package/docs/adr/0016-productive-surfaces-use-compact-density.md +62 -0
- package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +54 -0
- package/docs/relative-unit-scale.md +9 -2
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/evaluations.md +3 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +9 -3
- package/src/core/runtime.ts +7 -1
- package/src/core/types.ts +12 -3
- package/src/mcp/index.ts +4 -1
- package/src/ui/components/patterns/action-list-dialog.tsx +2 -2
- package/src/ui/components/patterns/content-header.tsx +1 -1
- package/src/ui/components/patterns/form-dialog.tsx +7 -2
- package/src/ui/components/patterns/list.tsx +239 -47
- package/src/ui/components/patterns/presentation.tsx +7 -5
- package/src/ui/components/patterns/state-surface.tsx +2 -2
- package/src/ui/components/patterns/surface-header.tsx +4 -4
- package/src/ui/components/primitives/alert.tsx +2 -2
- package/src/ui/components/primitives/breadcrumb.tsx +1 -1
- package/src/ui/components/primitives/button-group.tsx +1 -1
- package/src/ui/components/primitives/button.tsx +3 -3
- package/src/ui/components/primitives/calendar.tsx +1 -1
- package/src/ui/components/primitives/close-button.tsx +40 -0
- package/src/ui/components/primitives/detail.tsx +66 -28
- package/src/ui/components/primitives/dialog.tsx +36 -21
- package/src/ui/components/primitives/drawer.tsx +26 -19
- package/src/ui/components/primitives/empty-value.tsx +3 -3
- package/src/ui/components/primitives/empty.tsx +1 -1
- package/src/ui/components/primitives/field.tsx +12 -12
- package/src/ui/components/primitives/icon-picker.tsx +1 -1
- package/src/ui/components/primitives/input-group.tsx +1 -1
- package/src/ui/components/primitives/input.tsx +2 -2
- package/src/ui/components/primitives/item.tsx +5 -5
- package/src/ui/components/primitives/pagination.tsx +4 -4
- package/src/ui/components/primitives/select.tsx +2 -2
- package/src/ui/components/primitives/table.tsx +26 -17
- package/src/ui/components/primitives/tabs.tsx +80 -23
- package/src/ui/components/primitives/textarea.tsx +1 -1
- package/src/ui/components/primitives/toggle-group.tsx +9 -2
- package/src/ui/docs/content/action-form.md +19 -0
- package/src/ui/docs/content/action-list-dialog.md +1 -1
- package/src/ui/docs/content/action-list.md +7 -1
- package/src/ui/docs/content/alert.md +2 -0
- package/src/ui/docs/content/button.md +2 -1
- package/src/ui/docs/content/customization.md +11 -1
- package/src/ui/docs/content/detail.md +9 -8
- package/src/ui/docs/content/dialog.md +12 -5
- package/src/ui/docs/content/drawer.md +6 -3
- package/src/ui/docs/content/empty-value.md +4 -4
- package/src/ui/docs/content/field.md +1 -1
- package/src/ui/docs/content/item.md +2 -0
- package/src/ui/docs/content/page.md +1 -1
- package/src/ui/docs/content/presentation.md +2 -2
- package/src/ui/docs/content/tabs.md +16 -6
- package/src/ui/docs/content/toast.md +2 -1
- package/src/ui/docs/content/tokens.md +45 -2
- package/src/ui/react.tsx +1 -0
- package/src/ui/theme.css +3 -0
|
@@ -103,6 +103,11 @@ import {
|
|
|
103
103
|
} from "../primitives/input-group.tsx";
|
|
104
104
|
import { Separator } from "../primitives/separator.tsx";
|
|
105
105
|
import { Label } from "../primitives/label.tsx";
|
|
106
|
+
import {
|
|
107
|
+
FieldGroup,
|
|
108
|
+
FieldLegend,
|
|
109
|
+
FieldSet,
|
|
110
|
+
} from "../primitives/field.tsx";
|
|
106
111
|
import { Search } from "lucide-react";
|
|
107
112
|
import { Select, type SelectOption } from "../primitives/select.tsx";
|
|
108
113
|
import {
|
|
@@ -145,6 +150,10 @@ function isEmptyValue(v: unknown): boolean {
|
|
|
145
150
|
);
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
function filterPlacement(spec: FilterSpec): "inline" | "advanced" | "external" {
|
|
154
|
+
return spec.placement ?? (spec.advanced === true ? "advanced" : "inline");
|
|
155
|
+
}
|
|
156
|
+
|
|
148
157
|
// =============================================================================
|
|
149
158
|
// Tipos públicos
|
|
150
159
|
// =============================================================================
|
|
@@ -264,6 +273,9 @@ export interface ActionFilterBarProps {
|
|
|
264
273
|
state: ActionFilterState;
|
|
265
274
|
onStateChange: (next: ActionFilterState) => void;
|
|
266
275
|
filterOptions?: Record<string, SelectOption[]>;
|
|
276
|
+
/** Composição visual do diálogo de filtros avançados. A largura é derivada
|
|
277
|
+
* internamente do número de colunas. */
|
|
278
|
+
advancedFilters?: AdvancedFiltersLayout;
|
|
267
279
|
onRefresh?: () => Promise<void> | void;
|
|
268
280
|
refreshing?: boolean;
|
|
269
281
|
/** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
|
|
@@ -272,6 +284,11 @@ export interface ActionFilterBarProps {
|
|
|
272
284
|
actions?: ReactNode;
|
|
273
285
|
}
|
|
274
286
|
|
|
287
|
+
export interface AdvancedFiltersLayout {
|
|
288
|
+
/** Número de colunas dos grupos de filtros. */
|
|
289
|
+
columns?: 1 | 2 | 3;
|
|
290
|
+
}
|
|
291
|
+
|
|
275
292
|
/** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
|
|
276
293
|
* apresentação é de quem chama; o pattern dá o segment, o estado e os dados. */
|
|
277
294
|
export interface ActionListView<TItem> {
|
|
@@ -307,6 +324,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
307
324
|
cells?: Record<string, (item: TItem) => ReactNode>;
|
|
308
325
|
/** Opções de runtime pros filtros select/lookup (chave = nome do filtro). */
|
|
309
326
|
filterOptions?: Record<string, SelectOption[]>;
|
|
327
|
+
/** Composição visual do diálogo de filtros avançados. Default: uma coluna. */
|
|
328
|
+
advancedFilters?: AdvancedFiltersLayout;
|
|
310
329
|
/** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
|
|
311
330
|
* tabela ('table', quando há columns) e estas. Ex.: { board: { label, render } }. */
|
|
312
331
|
views?: Record<string, ActionListView<TItem>>;
|
|
@@ -547,9 +566,20 @@ function FilterField({
|
|
|
547
566
|
/>
|
|
548
567
|
);
|
|
549
568
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
569
|
+
if (spec.type === "date") {
|
|
570
|
+
return (
|
|
571
|
+
<DateFilterField
|
|
572
|
+
id={`filter-${name}`}
|
|
573
|
+
value={(value as string | undefined) ?? ""}
|
|
574
|
+
placeholder={placeholder}
|
|
575
|
+
className={cn("w-40", className)}
|
|
576
|
+
disabled={!enabled}
|
|
577
|
+
onChange={onChange}
|
|
578
|
+
/>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
// text · number: aplica no blur/Enter.
|
|
582
|
+
const type = spec.type === "number" ? "number" : "text";
|
|
553
583
|
return (
|
|
554
584
|
<ClearableInput
|
|
555
585
|
id={`filter-${name}`}
|
|
@@ -563,6 +593,106 @@ function FilterField({
|
|
|
563
593
|
);
|
|
564
594
|
}
|
|
565
595
|
|
|
596
|
+
function parseIsoDay(value: string): Date | undefined {
|
|
597
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
|
|
598
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
599
|
+
const parsed = new Date(year ?? 0, (month ?? 1) - 1, day ?? 1);
|
|
600
|
+
return parsed.getFullYear() === year &&
|
|
601
|
+
parsed.getMonth() === (month ?? 1) - 1 &&
|
|
602
|
+
parsed.getDate() === day
|
|
603
|
+
? parsed
|
|
604
|
+
: undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isValidDateFilterValue(value: unknown, multiple: boolean): boolean {
|
|
608
|
+
if (multiple) {
|
|
609
|
+
return (
|
|
610
|
+
Array.isArray(value) &&
|
|
611
|
+
value.length > 0 &&
|
|
612
|
+
value.every(
|
|
613
|
+
(item) => typeof item === "string" && parseIsoDay(item) !== undefined,
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return typeof value === "string" && parseIsoDay(value) !== undefined;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function toIsoDay(date: Date): string {
|
|
621
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function DateFilterField({
|
|
625
|
+
id,
|
|
626
|
+
value,
|
|
627
|
+
placeholder,
|
|
628
|
+
className,
|
|
629
|
+
disabled,
|
|
630
|
+
onChange,
|
|
631
|
+
}: {
|
|
632
|
+
id: string;
|
|
633
|
+
value: string;
|
|
634
|
+
placeholder: string;
|
|
635
|
+
className?: string;
|
|
636
|
+
disabled: boolean;
|
|
637
|
+
onChange: (value: unknown) => void;
|
|
638
|
+
}): React.ReactElement {
|
|
639
|
+
const [open, setOpen] = useState(false);
|
|
640
|
+
const selected = parseIsoDay(value);
|
|
641
|
+
const label =
|
|
642
|
+
selected?.toLocaleDateString("pt-BR", {
|
|
643
|
+
day: "2-digit",
|
|
644
|
+
month: "2-digit",
|
|
645
|
+
year: "numeric",
|
|
646
|
+
}) ?? placeholder;
|
|
647
|
+
|
|
648
|
+
return (
|
|
649
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
650
|
+
<PopoverTrigger asChild>
|
|
651
|
+
<Button
|
|
652
|
+
id={id}
|
|
653
|
+
type="button"
|
|
654
|
+
variant="outline"
|
|
655
|
+
className={cn("justify-start gap-2 font-normal", className)}
|
|
656
|
+
disabled={disabled}
|
|
657
|
+
>
|
|
658
|
+
<CalendarIcon className="h-3.5 w-3.5 shrink-0" />
|
|
659
|
+
<span className={cn("truncate", selected === undefined && "text-muted-foreground")}>
|
|
660
|
+
{label}
|
|
661
|
+
</span>
|
|
662
|
+
</Button>
|
|
663
|
+
</PopoverTrigger>
|
|
664
|
+
<PopoverContent align="start" className="w-auto p-0">
|
|
665
|
+
<Calendar
|
|
666
|
+
mode="single"
|
|
667
|
+
defaultMonth={selected}
|
|
668
|
+
selected={selected}
|
|
669
|
+
onSelect={(day) => {
|
|
670
|
+
if (day === undefined) return;
|
|
671
|
+
onChange(toIsoDay(day));
|
|
672
|
+
setOpen(false);
|
|
673
|
+
}}
|
|
674
|
+
/>
|
|
675
|
+
{selected !== undefined && (
|
|
676
|
+
<div className="border-t border-border p-2">
|
|
677
|
+
<Button
|
|
678
|
+
type="button"
|
|
679
|
+
variant="ghost"
|
|
680
|
+
size="sm"
|
|
681
|
+
className="w-full"
|
|
682
|
+
onClick={() => {
|
|
683
|
+
onChange("");
|
|
684
|
+
setOpen(false);
|
|
685
|
+
}}
|
|
686
|
+
>
|
|
687
|
+
Limpar data
|
|
688
|
+
</Button>
|
|
689
|
+
</div>
|
|
690
|
+
)}
|
|
691
|
+
</PopoverContent>
|
|
692
|
+
</Popover>
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
566
696
|
/** Input com limpar (a receita clearable dos textos): Input/InputGroup são locked, o X
|
|
567
697
|
* entra por composição — addon à direita, visível só com texto. Uncontrolled (aplica
|
|
568
698
|
* no blur/Enter, como a busca); o X zera o campo E aplica o vazio. */
|
|
@@ -831,6 +961,7 @@ function AdvancedFiltersDialog({
|
|
|
831
961
|
onApply,
|
|
832
962
|
filterOptions,
|
|
833
963
|
cascade,
|
|
964
|
+
columns = 1,
|
|
834
965
|
}: {
|
|
835
966
|
open: boolean;
|
|
836
967
|
onOpenChange: (o: boolean) => void;
|
|
@@ -838,6 +969,7 @@ function AdvancedFiltersDialog({
|
|
|
838
969
|
values: Record<string, unknown>;
|
|
839
970
|
onApply: (next: Record<string, unknown>) => void;
|
|
840
971
|
filterOptions?: Record<string, SelectOption[]> | undefined;
|
|
972
|
+
columns?: 1 | 2 | 3;
|
|
841
973
|
/** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
|
|
842
974
|
cascade: (
|
|
843
975
|
changed: string,
|
|
@@ -850,53 +982,83 @@ function AdvancedFiltersDialog({
|
|
|
850
982
|
if (open) setDraft(values);
|
|
851
983
|
}, [open, values]);
|
|
852
984
|
|
|
985
|
+
const sections = useMemo(() => {
|
|
986
|
+
const grouped = new Map<string, Array<[string, FilterSpec]>>();
|
|
987
|
+
for (const entry of specs) {
|
|
988
|
+
const section = text(entry[1].section, "Outros");
|
|
989
|
+
const current = grouped.get(section) ?? [];
|
|
990
|
+
current.push(entry);
|
|
991
|
+
grouped.set(section, current);
|
|
992
|
+
}
|
|
993
|
+
return [...grouped];
|
|
994
|
+
}, [specs]);
|
|
995
|
+
const sectioned = specs.some(([, spec]) => spec.section !== undefined);
|
|
996
|
+
const dialogWidth = {
|
|
997
|
+
1: "max-w-sm",
|
|
998
|
+
2: "max-w-2xl",
|
|
999
|
+
3: "max-w-4xl",
|
|
1000
|
+
}[columns];
|
|
1001
|
+
const gridColumns = {
|
|
1002
|
+
1: "grid-cols-1",
|
|
1003
|
+
2: "grid-cols-2",
|
|
1004
|
+
3: "grid-cols-3",
|
|
1005
|
+
}[columns];
|
|
1006
|
+
|
|
1007
|
+
const field = ([name, spec]: [string, FilterSpec]): React.ReactElement => (
|
|
1008
|
+
<div key={name} className="space-y-2">
|
|
1009
|
+
<Label htmlFor={`filter-${name}`}>{text(spec.label, name)}</Label>
|
|
1010
|
+
<FilterField
|
|
1011
|
+
name={name}
|
|
1012
|
+
spec={spec}
|
|
1013
|
+
value={draft[name]}
|
|
1014
|
+
allValues={draft}
|
|
1015
|
+
className="w-full"
|
|
1016
|
+
onChange={(v) => setDraft((d) => cascade(name, { ...d, [name]: v }))}
|
|
1017
|
+
options={optionsFor(name, spec, filterOptions)}
|
|
1018
|
+
/>
|
|
1019
|
+
</div>
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
const clear = (): void => {
|
|
1023
|
+
const cleared = { ...draft };
|
|
1024
|
+
for (const [name] of specs) delete cleared[name];
|
|
1025
|
+
onApply(cleared);
|
|
1026
|
+
onOpenChange(false);
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const apply = (): void => {
|
|
1030
|
+
onApply(draft);
|
|
1031
|
+
onOpenChange(false);
|
|
1032
|
+
};
|
|
1033
|
+
|
|
853
1034
|
return (
|
|
854
1035
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
855
|
-
{
|
|
856
|
-
subtítulo — o título basta (aria-describedby explícito cala o aviso do Radix). */}
|
|
857
|
-
<DialogContent className="sm:max-w-sm" aria-describedby={undefined}>
|
|
1036
|
+
<DialogContent className={dialogWidth} aria-describedby={undefined}>
|
|
858
1037
|
<DialogHeader>
|
|
859
1038
|
<DialogTitle>Filtros</DialogTitle>
|
|
860
1039
|
</DialogHeader>
|
|
861
1040
|
<DialogBody className="space-y-4">
|
|
862
|
-
{
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
</div>
|
|
877
|
-
))}
|
|
1041
|
+
{sectioned
|
|
1042
|
+
? sections.map(([section, entries]) => (
|
|
1043
|
+
<FieldSet key={section}>
|
|
1044
|
+
<FieldLegend variant="label">{section}</FieldLegend>
|
|
1045
|
+
<FieldGroup className={cn("grid gap-4", gridColumns)}>
|
|
1046
|
+
{entries.map(field)}
|
|
1047
|
+
</FieldGroup>
|
|
1048
|
+
</FieldSet>
|
|
1049
|
+
))
|
|
1050
|
+
: (
|
|
1051
|
+
<FieldGroup className={cn("grid gap-4", gridColumns)}>
|
|
1052
|
+
{specs.map(field)}
|
|
1053
|
+
</FieldGroup>
|
|
1054
|
+
)}
|
|
878
1055
|
</DialogBody>
|
|
879
1056
|
<DialogFooter>
|
|
880
1057
|
<ButtonGroup mode="spaced" distribution="equal">
|
|
881
|
-
<Button
|
|
882
|
-
variant="outline"
|
|
883
|
-
onClick={() => {
|
|
884
|
-
const cleared = { ...draft };
|
|
885
|
-
for (const [name] of specs) delete cleared[name];
|
|
886
|
-
onApply(cleared);
|
|
887
|
-
onOpenChange(false);
|
|
888
|
-
}}
|
|
889
|
-
>
|
|
1058
|
+
<Button variant="outline" onClick={clear}>
|
|
890
1059
|
Limpar
|
|
891
1060
|
</Button>
|
|
892
|
-
<Button
|
|
893
|
-
onClick={() => {
|
|
894
|
-
onApply(draft);
|
|
895
|
-
onOpenChange(false);
|
|
896
|
-
}}
|
|
897
|
-
>
|
|
898
|
-
Aplicar
|
|
899
|
-
</Button>
|
|
1061
|
+
<Button onClick={apply}>Aplicar</Button>
|
|
900
1062
|
</ButtonGroup>
|
|
901
1063
|
</DialogFooter>
|
|
902
1064
|
</DialogContent>
|
|
@@ -910,6 +1072,7 @@ export function ActionFilterBar({
|
|
|
910
1072
|
state,
|
|
911
1073
|
onStateChange,
|
|
912
1074
|
filterOptions,
|
|
1075
|
+
advancedFilters,
|
|
913
1076
|
onRefresh,
|
|
914
1077
|
refreshing = false,
|
|
915
1078
|
controls,
|
|
@@ -917,7 +1080,12 @@ export function ActionFilterBar({
|
|
|
917
1080
|
}: ActionFilterBarProps): React.ReactElement {
|
|
918
1081
|
const dicts = useDicts();
|
|
919
1082
|
const filterSpecs = Object.entries(action.filters ?? {});
|
|
920
|
-
const
|
|
1083
|
+
const visibleFilterSpecs = filterSpecs.filter(
|
|
1084
|
+
([, spec]) => filterPlacement(spec) !== "external",
|
|
1085
|
+
);
|
|
1086
|
+
const inlineSpecs = visibleFilterSpecs.filter(
|
|
1087
|
+
([, spec]) => filterPlacement(spec) === "inline",
|
|
1088
|
+
);
|
|
921
1089
|
const hasSearch = (action.text?.fields.length ?? 0) > 0;
|
|
922
1090
|
const periods = action.periods ?? [];
|
|
923
1091
|
const defaultPeriod =
|
|
@@ -976,9 +1144,9 @@ export function ActionFilterBar({
|
|
|
976
1144
|
const overflowSpecs = Number.isFinite(inlineFit)
|
|
977
1145
|
? inlineSpecs.slice(inlineFit)
|
|
978
1146
|
: [];
|
|
979
|
-
const modalSpecs =
|
|
1147
|
+
const modalSpecs = visibleFilterSpecs.filter(
|
|
980
1148
|
([name, spec]) =>
|
|
981
|
-
spec
|
|
1149
|
+
filterPlacement(spec) === "advanced" ||
|
|
982
1150
|
overflowSpecs.some(([overflowName]) => overflowName === name),
|
|
983
1151
|
);
|
|
984
1152
|
const setFilter = (name: string, value: unknown): void => {
|
|
@@ -1003,7 +1171,7 @@ export function ActionFilterBar({
|
|
|
1003
1171
|
};
|
|
1004
1172
|
});
|
|
1005
1173
|
const searchFirst =
|
|
1006
|
-
hasSearch && periods.length === 0 &&
|
|
1174
|
+
hasSearch && periods.length === 0 && visibleFilterSpecs.length === 0;
|
|
1007
1175
|
const searchBox = hasSearch ? (
|
|
1008
1176
|
<ClearableInput
|
|
1009
1177
|
className="w-64 min-w-36"
|
|
@@ -1139,6 +1307,7 @@ export function ActionFilterBar({
|
|
|
1139
1307
|
cascade={cascadeClear}
|
|
1140
1308
|
onApply={(filters) => onStateChange({ ...state, filters })}
|
|
1141
1309
|
filterOptions={filterOptions}
|
|
1310
|
+
columns={advancedFilters?.columns}
|
|
1142
1311
|
/>
|
|
1143
1312
|
)}
|
|
1144
1313
|
</div>
|
|
@@ -1182,6 +1351,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1182
1351
|
columns,
|
|
1183
1352
|
cells,
|
|
1184
1353
|
filterOptions,
|
|
1354
|
+
advancedFilters,
|
|
1185
1355
|
views,
|
|
1186
1356
|
batch,
|
|
1187
1357
|
rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
|
|
@@ -1235,7 +1405,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1235
1405
|
const setStateResetPage = (next: ActionListState): void =>
|
|
1236
1406
|
setState({ ...next, page: 1 });
|
|
1237
1407
|
|
|
1238
|
-
const filterSpecs = Object.entries(action.filters ?? {})
|
|
1408
|
+
const filterSpecs = Object.entries(action.filters ?? {}).filter(
|
|
1409
|
+
([, spec]) => filterPlacement(spec) !== "external",
|
|
1410
|
+
);
|
|
1239
1411
|
const hasSearch = (action.text?.fields.length ?? 0) > 0;
|
|
1240
1412
|
|
|
1241
1413
|
// — Views: 'table' (quando há colunas) + as nomeadas. O segment só existe com 2+. —
|
|
@@ -1263,7 +1435,14 @@ export function ActionList<TInput, TItem>({
|
|
|
1263
1435
|
const effectiveInput = useMemo(() => {
|
|
1264
1436
|
const filled: Record<string, unknown> = {};
|
|
1265
1437
|
for (const [name, v] of Object.entries(state.filters)) {
|
|
1266
|
-
if (
|
|
1438
|
+
if (isEmptyValue(v)) continue;
|
|
1439
|
+
const filter = action.filters?.[name];
|
|
1440
|
+
if (
|
|
1441
|
+
filter?.type === "date" &&
|
|
1442
|
+
!isValidDateFilterValue(v, filter.multiple === true)
|
|
1443
|
+
)
|
|
1444
|
+
continue;
|
|
1445
|
+
filled[name] = v;
|
|
1267
1446
|
}
|
|
1268
1447
|
// Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
|
|
1269
1448
|
// range. state.period null = o default do contrato (sempre aplicado).
|
|
@@ -1438,6 +1617,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1438
1617
|
state={state}
|
|
1439
1618
|
onStateChange={(next) => setStateResetPage({ ...state, ...next })}
|
|
1440
1619
|
filterOptions={filterOptions}
|
|
1620
|
+
advancedFilters={advancedFilters}
|
|
1441
1621
|
onRefresh={() => refetch()}
|
|
1442
1622
|
refreshing={isLoading}
|
|
1443
1623
|
controls={
|
|
@@ -1901,8 +2081,14 @@ export function listParamsToState(
|
|
|
1901
2081
|
const filters: Record<string, unknown> = {};
|
|
1902
2082
|
for (const [name, spec] of Object.entries(action.filters ?? {})) {
|
|
1903
2083
|
const v = params.get(name);
|
|
1904
|
-
if (v
|
|
1905
|
-
|
|
2084
|
+
if (v === null || v === "") continue;
|
|
2085
|
+
const value = spec.multiple === true ? v.split(",") : v;
|
|
2086
|
+
if (
|
|
2087
|
+
spec.type === "date" &&
|
|
2088
|
+
!isValidDateFilterValue(value, spec.multiple === true)
|
|
2089
|
+
)
|
|
2090
|
+
continue;
|
|
2091
|
+
filters[name] = value;
|
|
1906
2092
|
}
|
|
1907
2093
|
const cols = params.get("cols");
|
|
1908
2094
|
const limitParam = Number(params.get("limit") ?? "");
|
|
@@ -1945,6 +2131,12 @@ export function listStateToParams(
|
|
|
1945
2131
|
}
|
|
1946
2132
|
for (const [name, value] of Object.entries(state.filters)) {
|
|
1947
2133
|
if (isEmptyValue(value)) continue;
|
|
2134
|
+
const filter = action.filters?.[name];
|
|
2135
|
+
if (
|
|
2136
|
+
filter?.type === "date" &&
|
|
2137
|
+
!isValidDateFilterValue(value, filter.multiple === true)
|
|
2138
|
+
)
|
|
2139
|
+
continue;
|
|
1948
2140
|
qs.set(name, Array.isArray(value) ? value.join(",") : String(value));
|
|
1949
2141
|
}
|
|
1950
2142
|
if (state.period === "custom") {
|
|
@@ -136,7 +136,10 @@ function PresentationFrame({
|
|
|
136
136
|
{hasBodyFooter ? (
|
|
137
137
|
<span
|
|
138
138
|
ref={setFooterNode}
|
|
139
|
-
className=
|
|
139
|
+
className={cn(
|
|
140
|
+
"contents",
|
|
141
|
+
equal && "[&>*]:min-w-0 [&>*]:w-full [&>*]:flex-1",
|
|
142
|
+
)}
|
|
140
143
|
/>
|
|
141
144
|
) : null}
|
|
142
145
|
{footerActions}
|
|
@@ -258,7 +261,7 @@ function PresentationFrame({
|
|
|
258
261
|
</DialogHeader>
|
|
259
262
|
<DialogBody>{body}</DialogBody>
|
|
260
263
|
{hasFooter ? (
|
|
261
|
-
<DialogFooter>{renderFooterActions(
|
|
264
|
+
<DialogFooter>{renderFooterActions()}</DialogFooter>
|
|
262
265
|
) : null}
|
|
263
266
|
</DialogContent>
|
|
264
267
|
</Dialog>
|
|
@@ -293,7 +296,7 @@ function PresentationFrame({
|
|
|
293
296
|
</DrawerHeader>
|
|
294
297
|
<DrawerBody>{body}</DrawerBody>
|
|
295
298
|
{hasFooter ? (
|
|
296
|
-
<DrawerFooter>{renderFooterActions(
|
|
299
|
+
<DrawerFooter>{renderFooterActions()}</DrawerFooter>
|
|
297
300
|
) : null}
|
|
298
301
|
</DrawerContent>
|
|
299
302
|
</Drawer>
|
|
@@ -554,7 +557,6 @@ export function Presentation({
|
|
|
554
557
|
defaultValues={bodyInput}
|
|
555
558
|
submitLabel={definition.body.submitLabel}
|
|
556
559
|
onCancel={invocation.surface === "page" ? undefined : closeSurface}
|
|
557
|
-
cancelVariant="outline"
|
|
558
560
|
onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
|
|
559
561
|
disabled={hasSurfaceBlock}
|
|
560
562
|
onLoadingChange={setBodyLoading}
|
|
@@ -695,7 +697,7 @@ function PresentationInspectionDialog({
|
|
|
695
697
|
|
|
696
698
|
return (
|
|
697
699
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
698
|
-
<DialogContent className="
|
|
700
|
+
<DialogContent className="max-w-3xl" aria-describedby={undefined}>
|
|
699
701
|
<DialogHeader>
|
|
700
702
|
<DialogTitle>{title}</DialogTitle>
|
|
701
703
|
</DialogHeader>
|
|
@@ -238,8 +238,8 @@ export function EmptySurface({
|
|
|
238
238
|
data-frame={frame}
|
|
239
239
|
className={cn(
|
|
240
240
|
frame === 'structural' && 'border-solid',
|
|
241
|
-
frame === 'bare' && 'border-0 p-0
|
|
242
|
-
compact && 'gap-4 p-6
|
|
241
|
+
frame === 'bare' && 'border-0 p-0',
|
|
242
|
+
compact && 'gap-4 p-6',
|
|
243
243
|
className,
|
|
244
244
|
)}
|
|
245
245
|
>
|
|
@@ -28,13 +28,13 @@ export const surfaceHeaderClasses: Record<
|
|
|
28
28
|
title: "text-3xl font-semibold tracking-tight",
|
|
29
29
|
description: "mt-1 truncate text-sm text-muted-foreground",
|
|
30
30
|
count: "font-mono text-base text-muted-foreground/60",
|
|
31
|
-
actions: "flex w-
|
|
31
|
+
actions: "flex w-auto shrink-0 items-center gap-4",
|
|
32
32
|
},
|
|
33
33
|
section: {
|
|
34
34
|
title: "text-sm font-semibold",
|
|
35
35
|
description: "mt-0.5 truncate text-xs text-muted-foreground",
|
|
36
36
|
count: "font-mono text-xs text-muted-foreground/60",
|
|
37
|
-
actions: "flex w-
|
|
37
|
+
actions: "flex w-auto shrink-0 items-center gap-2",
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
|
|
@@ -182,7 +182,7 @@ export function SurfaceHeader({
|
|
|
182
182
|
data-slot={`${slot}-header-row`}
|
|
183
183
|
className={cn(
|
|
184
184
|
actionSlots.length > 0 &&
|
|
185
|
-
"flex flex-
|
|
185
|
+
"flex flex-row items-end justify-between gap-4",
|
|
186
186
|
)}
|
|
187
187
|
>
|
|
188
188
|
{heading}
|
|
@@ -204,7 +204,7 @@ export function SurfaceHeader({
|
|
|
204
204
|
leadingPlacement !== "inline" &&
|
|
205
205
|
leading.length === 0 &&
|
|
206
206
|
actionSlots.length > 0 &&
|
|
207
|
-
"flex flex-
|
|
207
|
+
"flex flex-row items-end justify-between gap-4",
|
|
208
208
|
className,
|
|
209
209
|
)}
|
|
210
210
|
{...props}
|
|
@@ -50,7 +50,7 @@ function resolveAlertStyle({ context, variant }: AlertStyleOptions = {}): {
|
|
|
50
50
|
export function alertVariants(options: AlertStyleOptions = {}): string {
|
|
51
51
|
const resolved = resolveAlertStyle(options);
|
|
52
52
|
return cn(
|
|
53
|
-
"relative grid w-full grid-cols-[0_minmax(0,1fr)_auto] items-stretch gap-x-0 gap-y-1 rounded-lg border p-
|
|
53
|
+
"relative grid w-full grid-cols-[0_minmax(0,1fr)_auto] items-stretch gap-x-0 gap-y-1 rounded-lg border p-3 text-sm has-[>[data-slot=alert-media]]:grid-cols-[3rem_minmax(0,1fr)_auto]",
|
|
54
54
|
resolved.variant === "outline"
|
|
55
55
|
? semanticOutlineClasses[resolved.context]
|
|
56
56
|
: semanticSubtleClasses[resolved.context],
|
|
@@ -282,7 +282,7 @@ export function AlertDescription({
|
|
|
282
282
|
<div
|
|
283
283
|
data-slot="alert-description"
|
|
284
284
|
className={cn(
|
|
285
|
-
"text-
|
|
285
|
+
"text-muted-foreground [&>*+*]:mt-1 [&_p]:leading-relaxed",
|
|
286
286
|
className,
|
|
287
287
|
)}
|
|
288
288
|
{...props}
|
|
@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
|
13
13
|
<ol
|
|
14
14
|
data-slot="breadcrumb-list"
|
|
15
15
|
className={cn(
|
|
16
|
-
"flex flex-wrap items-center gap-
|
|
16
|
+
"flex flex-wrap items-center gap-2.5 text-sm break-words text-muted-foreground",
|
|
17
17
|
className
|
|
18
18
|
)}
|
|
19
19
|
{...props}
|
|
@@ -56,9 +56,9 @@ const subtleClasses: Record<ButtonContext, string> = {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const outlineClasses: Record<ButtonContext, string> = {
|
|
59
|
-
neutral: 'border border-input bg-
|
|
60
|
-
primary: 'border border-input bg-
|
|
61
|
-
danger: 'border border-context-danger-border bg-
|
|
59
|
+
neutral: 'border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
60
|
+
primary: 'border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
61
|
+
danger: 'border border-context-danger-border bg-transparent text-context-danger-emphasis hover:bg-context-danger-subtle',
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const ghostClasses: Record<ButtonContext, string> = {
|
|
@@ -71,7 +71,7 @@ function Calendar({
|
|
|
71
71
|
classNames={{
|
|
72
72
|
root: cn("w-fit", defaultClassNames.root),
|
|
73
73
|
months: cn(
|
|
74
|
-
"relative flex flex-
|
|
74
|
+
"relative flex flex-row gap-4",
|
|
75
75
|
defaultClassNames.months,
|
|
76
76
|
),
|
|
77
77
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { XIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { Button, type ButtonProps } from "./button.tsx";
|
|
5
|
+
|
|
6
|
+
export type CloseButtonSize = "xs" | "sm";
|
|
7
|
+
|
|
8
|
+
const buttonSizeByCloseSize = {
|
|
9
|
+
xs: "icon-xs",
|
|
10
|
+
sm: "icon-sm",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export interface CloseButtonProps extends Omit<
|
|
14
|
+
ButtonProps,
|
|
15
|
+
"busy" | "children" | "context" | "icon" | "shape" | "size" | "type" | "variant"
|
|
16
|
+
> {
|
|
17
|
+
size?: CloseButtonSize;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CloseButton({
|
|
21
|
+
size = "sm",
|
|
22
|
+
disabled = false,
|
|
23
|
+
"aria-label": ariaLabel = "Fechar",
|
|
24
|
+
...props
|
|
25
|
+
}: CloseButtonProps): React.ReactElement {
|
|
26
|
+
return (
|
|
27
|
+
<Button
|
|
28
|
+
{...props}
|
|
29
|
+
type="button"
|
|
30
|
+
context="neutral"
|
|
31
|
+
variant="subtle"
|
|
32
|
+
size={buttonSizeByCloseSize[size]}
|
|
33
|
+
shape="pill"
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
aria-label={ariaLabel}
|
|
36
|
+
>
|
|
37
|
+
<XIcon aria-hidden />
|
|
38
|
+
</Button>
|
|
39
|
+
);
|
|
40
|
+
}
|