@softize/opus 18.2.0 → 18.3.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.
@@ -24,11 +24,11 @@ import {
24
24
  Fragment,
25
25
  isValidElement,
26
26
  useEffect,
27
- useLayoutEffect,
28
27
  useId,
29
28
  useMemo,
30
29
  useRef,
31
30
  useState,
31
+ type ComponentProps,
32
32
  type ReactNode,
33
33
  } from "react";
34
34
  import {
@@ -36,6 +36,8 @@ import {
36
36
  ArrowUp,
37
37
  ArrowUpDown,
38
38
  Calendar as CalendarIcon,
39
+ Check,
40
+ ChevronDown,
39
41
  Eraser,
40
42
  Filter,
41
43
  RotateCw,
@@ -67,6 +69,7 @@ import { cn } from "../../lib/cn.ts";
67
69
  import { objectSchemaShape } from "../../lib/object-schema.ts";
68
70
  import { Badge } from "../primitives/badge.tsx";
69
71
  import { DictionaryValue } from "../primitives/dictionary-value.tsx";
72
+ import { Dot } from "../primitives/dot.tsx";
70
73
  import { EmptyValue, isAbsentValue } from "../primitives/empty-value.tsx";
71
74
  import {
72
75
  Pagination,
@@ -81,6 +84,12 @@ import { Button } from "../primitives/button.tsx";
81
84
  import { ButtonGroup } from "../primitives/button-group.tsx";
82
85
  import { Calendar } from "../primitives/calendar.tsx";
83
86
  import { Checkbox } from "../primitives/checkbox.tsx";
87
+ import {
88
+ Command,
89
+ CommandGroup,
90
+ CommandItem,
91
+ CommandList,
92
+ } from "../primitives/command.tsx";
84
93
  import {
85
94
  Popover,
86
95
  PopoverContent,
@@ -103,11 +112,7 @@ import {
103
112
  } from "../primitives/input-group.tsx";
104
113
  import { Separator } from "../primitives/separator.tsx";
105
114
  import { Label } from "../primitives/label.tsx";
106
- import {
107
- FieldGroup,
108
- FieldLegend,
109
- FieldSet,
110
- } from "../primitives/field.tsx";
115
+ import { FieldGroup, FieldLegend, FieldSet } from "../primitives/field.tsx";
111
116
  import { Search } from "lucide-react";
112
117
  import { Select, type SelectOption } from "../primitives/select.tsx";
113
118
  import {
@@ -273,20 +278,35 @@ export interface ActionFilterBarProps {
273
278
  state: ActionFilterState;
274
279
  onStateChange: (next: ActionFilterState) => void;
275
280
  filterOptions?: Record<string, SelectOption[]>;
281
+ /** Valor de repouso por filtro simples. O limpar só aparece fora desse estado e retorna a ele. */
282
+ filterDefaults?: Record<string, string>;
283
+ /** Ícone leading por filtro, projetado no controle derivado sem alterar o contrato. */
284
+ filterIcons?: Record<string, ReactNode>;
276
285
  /** Composição visual do diálogo de filtros avançados. A largura é derivada
277
286
  * internamente do número de colunas. */
278
287
  advancedFilters?: AdvancedFiltersLayout;
279
288
  onRefresh?: () => Promise<void> | void;
280
289
  refreshing?: boolean;
290
+ /** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
291
+ periodPosition?: "before" | "after" | { afterFilter: string };
292
+ /** Composição do período com uma dimensão temporal declarada como filtro inline. */
293
+ period?: PeriodLayout;
281
294
  /** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
282
295
  controls?: ReactNode;
283
296
  /** Ações do consumidor, separadas do grupo de controles. */
284
297
  actions?: ReactNode;
285
298
  }
286
299
 
300
+ export interface PeriodLayout {
301
+ /** Nome do filtro inline que escolhe qual data o período recorta. */
302
+ basisFilter: string;
303
+ }
304
+
287
305
  export interface AdvancedFiltersLayout {
288
306
  /** Número de colunas dos grupos de filtros. */
289
307
  columns?: 1 | 2 | 3;
308
+ /** Ícone de cada seção, indexado pelo mesmo rótulo declarado nos filtros. */
309
+ sectionIcons?: Record<string, ReactNode>;
290
310
  }
291
311
 
292
312
  /** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
@@ -316,6 +336,8 @@ export interface ActionListProps<TInput, TItem> {
316
336
  action: ListActionLike;
317
337
  /** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
318
338
  input: TInput;
339
+ /** `fill` ocupa a altura disponível e mantém a rolagem dentro da região de resultados. */
340
+ layout?: "flow" | "fill";
319
341
  /** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
320
342
  toolbarActions?: ReactNode;
321
343
  /** Tabela explícita — sobrepõe as colunas do contrato. */
@@ -324,8 +346,16 @@ export interface ActionListProps<TInput, TItem> {
324
346
  cells?: Record<string, (item: TItem) => ReactNode>;
325
347
  /** Opções de runtime pros filtros select/lookup (chave = nome do filtro). */
326
348
  filterOptions?: Record<string, SelectOption[]>;
349
+ /** Valor de repouso por filtro simples. */
350
+ filterDefaults?: Record<string, string>;
351
+ /** Ícone leading por filtro, projetado no controle derivado sem alterar o contrato. */
352
+ filterIcons?: Record<string, ReactNode>;
327
353
  /** Composição visual do diálogo de filtros avançados. Default: uma coluna. */
328
354
  advancedFilters?: AdvancedFiltersLayout;
355
+ /** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
356
+ periodPosition?: "before" | "after" | { afterFilter: string };
357
+ /** Composição do período com uma dimensão temporal declarada como filtro inline. */
358
+ period?: PeriodLayout;
329
359
  /** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
330
360
  * tabela ('table', quando há columns) e estas. Ex.: { board: { label, render } }. */
331
361
  views?: Record<string, ActionListView<TItem>>;
@@ -478,6 +508,9 @@ function FilterField({
478
508
  value,
479
509
  onChange,
480
510
  options,
511
+ icon,
512
+ embedded = false,
513
+ defaultValue = "",
481
514
  allValues,
482
515
  className,
483
516
  }: {
@@ -486,11 +519,16 @@ function FilterField({
486
519
  value: unknown;
487
520
  onChange: (v: unknown) => void;
488
521
  options: SelectOption[];
522
+ /** Ícone leading do controle, definido pela superfície consumidora. */
523
+ icon?: ReactNode;
524
+ /** Remove a casca própria quando o filtro participa de um controle composto. */
525
+ embedded?: boolean;
526
+ /** Valor ao qual a ação de limpar retorna. */
527
+ defaultValue?: string;
489
528
  /** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
490
529
  allValues: Record<string, unknown>;
491
- /** Largura do contexto: inline, cada tipo tem largura fixa e previsível (select `w-40`,
492
- * lookup e múltiplo `w-52`, texto `w-40`); nunca cresce pelo conteúdo. O modal manda
493
- * `w-full`, que vence pelo merge de classes. */
530
+ /** Largura do contexto. Sem override, selects de valor único acompanham o valor dentro
531
+ * de limites previsíveis; múltiplo e texto mantêm largura fixa. O modal manda `w-full`. */
494
532
  className?: string;
495
533
  }): React.ReactElement {
496
534
  const label = text(spec.label, name);
@@ -504,7 +542,21 @@ function FilterField({
504
542
  // Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
505
543
  // sem nenhuma, as do lookup (server-side).
506
544
  const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
507
- const effOptions = base.length > 0 ? base : lookup.options;
545
+ const effOptions =
546
+ lookup.onSearch !== undefined && base.length > 0
547
+ ? [
548
+ ...base,
549
+ ...lookup.options.filter(
550
+ (candidate) =>
551
+ !base.some((option) => option.value === candidate.value),
552
+ ),
553
+ ]
554
+ : base.length > 0
555
+ ? base
556
+ : lookup.options;
557
+ const singleValue = (value as string | undefined) ?? defaultValue;
558
+ const changedFromDefault = singleValue !== defaultValue;
559
+ const acceptsEmptyValue = spec.allowEmpty !== false;
508
560
 
509
561
  if (spec.multiple === true) {
510
562
  return (
@@ -516,8 +568,9 @@ function FilterField({
516
568
  value={(value as string[] | undefined) ?? []}
517
569
  onChange={(v) => onChange(v)}
518
570
  options={effOptions}
571
+ icon={icon}
519
572
  placeholder={placeholder}
520
- className={cn("w-52", className)}
573
+ className={className ?? "w-52"}
521
574
  disabled={!enabled}
522
575
  {...(lookup.onSearch !== undefined
523
576
  ? { onSearch: lookup.onSearch, loading: lookup.loading }
@@ -528,16 +581,24 @@ function FilterField({
528
581
  if (spec.type === "lookup") {
529
582
  // Lookup é typeahead por natureza: Select searchable single (server-side quando a origem
530
583
  // é uma action; client quando as opções vieram estáticas/de runtime).
584
+ const ALL = "__all__";
531
585
  return (
532
586
  <Select
533
587
  searchable
534
- clearable
588
+ clearable={embedded ? false : changedFromDefault}
535
589
  id={`filter-${name}`}
536
- value={(value as string | undefined) ?? ""}
537
- onChange={(v) => onChange(v)}
538
- options={effOptions}
590
+ value={singleValue === "" ? ALL : singleValue}
591
+ onChange={(v) => onChange(v === "" ? defaultValue : v === ALL ? "" : v)}
592
+ options={[
593
+ ...(acceptsEmptyValue
594
+ ? [{ value: ALL, label: text(spec.placeholder, "Todos") }]
595
+ : []),
596
+ ...effOptions.filter((option) => option.value !== ALL),
597
+ ]}
598
+ icon={icon}
599
+ variant={embedded ? "ghost" : "default"}
539
600
  placeholder={placeholder}
540
- className={cn("w-52", className)}
601
+ className={className ?? "w-fit min-w-0 max-w-full"}
541
602
  disabled={!enabled}
542
603
  {...(lookup.onSearch !== undefined
543
604
  ? { onSearch: lookup.onSearch, loading: lookup.loading }
@@ -551,18 +612,21 @@ function FilterField({
551
612
  const ALL = "__all__";
552
613
  return (
553
614
  <Select
554
- clearable
615
+ searchable={spec.searchable === true}
616
+ clearable={embedded ? false : changedFromDefault}
555
617
  id={`filter-${name}`}
556
- value={
557
- ((value as string | undefined) ?? "") === "" ? ALL : (value as string)
558
- }
559
- onChange={(v) => onChange(v === ALL ? "" : v)}
618
+ value={singleValue === "" ? ALL : singleValue}
619
+ onChange={(v) => onChange(v === "" ? defaultValue : v === ALL ? "" : v)}
560
620
  options={[
561
- { value: ALL, label: text(spec.placeholder, "Todos") },
621
+ ...(acceptsEmptyValue
622
+ ? [{ value: ALL, label: text(spec.placeholder, "Todos") }]
623
+ : []),
562
624
  ...base,
563
625
  ]}
626
+ icon={icon}
627
+ variant={embedded ? "ghost" : "default"}
564
628
  disabled={!enabled}
565
- className={cn("w-40", className)}
629
+ className={className ?? "w-fit min-w-0 max-w-full"}
566
630
  />
567
631
  );
568
632
  }
@@ -570,11 +634,11 @@ function FilterField({
570
634
  return (
571
635
  <DateFilterField
572
636
  id={`filter-${name}`}
573
- value={(value as string | undefined) ?? ""}
637
+ value={singleValue}
574
638
  placeholder={placeholder}
575
639
  className={cn("w-40", className)}
576
640
  disabled={!enabled}
577
- onChange={onChange}
641
+ onChange={(next) => onChange(next === "" ? defaultValue : next)}
578
642
  />
579
643
  );
580
644
  }
@@ -584,7 +648,8 @@ function FilterField({
584
648
  <ClearableInput
585
649
  id={`filter-${name}`}
586
650
  type={type}
587
- applied={(value as string | undefined) ?? ""}
651
+ applied={singleValue}
652
+ clearValue={defaultValue}
588
653
  placeholder={placeholder}
589
654
  className={cn("w-40", className)}
590
655
  disabled={!enabled}
@@ -656,7 +721,12 @@ function DateFilterField({
656
721
  disabled={disabled}
657
722
  >
658
723
  <CalendarIcon className="h-3.5 w-3.5 shrink-0" />
659
- <span className={cn("truncate", selected === undefined && "text-muted-foreground")}>
724
+ <span
725
+ className={cn(
726
+ "truncate",
727
+ selected === undefined && "text-muted-foreground",
728
+ )}
729
+ >
660
730
  {label}
661
731
  </span>
662
732
  </Button>
@@ -694,12 +764,14 @@ function DateFilterField({
694
764
  }
695
765
 
696
766
  /** Input com limpar (a receita clearable dos textos): Input/InputGroup são locked, o X
697
- * entra por composição — addon à direita, visível só com texto. Uncontrolled (aplica
698
- * no blur/Enter, como a busca); o X zera o campo E aplica o vazio. */
767
+ * entra por composição — addon à direita, visível quando o valor diverge do repouso.
768
+ * Uncontrolled (aplica no blur/Enter, como a busca); o X restaura e aplica o repouso. */
699
769
  function ClearableInput({
700
770
  id,
701
771
  type,
702
772
  applied,
773
+ clearValue = "",
774
+ shape = "default",
703
775
  placeholder,
704
776
  className,
705
777
  leading,
@@ -709,6 +781,10 @@ function ClearableInput({
709
781
  id?: string;
710
782
  type?: string;
711
783
  applied: string;
784
+ /** Valor restaurado pela ação de limpar. */
785
+ clearValue?: string;
786
+ /** Forma visual do grupo; a busca da toolbar usa pill. */
787
+ shape?: ComponentProps<typeof InputGroup>["shape"];
712
788
  placeholder?: string;
713
789
  className?: string;
714
790
  /** Addon à esquerda (ex.: a lupa da busca). */
@@ -717,15 +793,15 @@ function ClearableInput({
717
793
  onApply: (v: string) => void;
718
794
  }): React.ReactElement {
719
795
  const inputRef = useRef<HTMLInputElement | null>(null);
720
- const [hasText, setHasText] = useState(applied !== "");
796
+ const [canClear, setCanClear] = useState(applied !== clearValue);
721
797
  // Valor aplicado mudou por fora (chip removido, URL) → re-sincroniza o campo.
722
798
  useEffect(() => {
723
799
  if (inputRef.current !== null && inputRef.current.value !== applied)
724
800
  inputRef.current.value = applied;
725
- setHasText(applied !== "");
726
- }, [applied]);
801
+ setCanClear(applied !== clearValue);
802
+ }, [applied, clearValue]);
727
803
  return (
728
- <InputGroup className={className}>
804
+ <InputGroup className={className} shape={shape}>
729
805
  {leading !== undefined && <InputGroupAddon>{leading}</InputGroupAddon>}
730
806
  <InputGroupInput
731
807
  ref={inputRef}
@@ -735,7 +811,7 @@ function ClearableInput({
735
811
  disabled={disabled}
736
812
  defaultValue={applied}
737
813
  placeholder={placeholder}
738
- onChange={(e) => setHasText(e.target.value !== "")}
814
+ onChange={(e) => setCanClear(e.target.value !== clearValue)}
739
815
  onBlur={(e) => {
740
816
  if (e.target.value !== applied) onApply(e.target.value);
741
817
  }}
@@ -746,14 +822,17 @@ function ClearableInput({
746
822
  }
747
823
  }}
748
824
  />
749
- {hasText && (
825
+ {canClear && (
750
826
  <InputGroupAddon align="inline-end">
751
827
  <InputGroupButton
828
+ size="icon-xs"
752
829
  aria-label="Limpar"
830
+ className="text-muted-foreground hover:text-foreground"
753
831
  onClick={() => {
754
- if (inputRef.current !== null) inputRef.current.value = "";
755
- setHasText(false);
756
- onApply("");
832
+ if (inputRef.current !== null)
833
+ inputRef.current.value = clearValue;
834
+ setCanClear(false);
835
+ onApply(clearValue);
757
836
  }}
758
837
  >
759
838
  <X className="size-3.5" />
@@ -792,23 +871,34 @@ function Labeled({
792
871
  label,
793
872
  children,
794
873
  slot,
874
+ showLabel = true,
875
+ className,
795
876
  }: {
796
877
  label: string;
797
878
  children: ReactNode;
798
- /** data-slot do wrapper (ex.: marca os filtros inline pro overflow responsivo). */
879
+ /** data-slot do wrapper (ex.: identifica os filtros inline na barra). */
799
880
  slot?: string;
881
+ /** Omite a label quando o controle é o único recorte inline da barra. */
882
+ showLabel?: boolean;
883
+ /** Limite externo do controle; o wrapper pode encolher junto com a toolbar. */
884
+ className?: string;
800
885
  }): React.ReactElement {
801
886
  return (
802
- <div data-slot={slot} className="flex shrink-0 flex-col gap-1">
887
+ <div
888
+ data-slot={slot}
889
+ className={cn("flex min-w-0 shrink flex-col gap-1", className)}
890
+ >
803
891
  {/* w-0 + min-w-full: a label acompanha a largura do controle e nunca a dita —
804
892
  um rótulo longo trunca em vez de alargar o filtro. */}
805
- <span
806
- data-slot="action-filter-label"
807
- title={label}
808
- className="block w-0 min-w-full truncate px-0.5 text-xs font-medium text-muted-foreground"
809
- >
810
- {label}
811
- </span>
893
+ {showLabel ? (
894
+ <span
895
+ data-slot="action-filter-label"
896
+ title={label}
897
+ className="block w-0 min-w-full truncate px-0.5 text-xs font-medium text-muted-foreground"
898
+ >
899
+ {label}
900
+ </span>
901
+ ) : null}
812
902
  {children}
813
903
  </div>
814
904
  );
@@ -829,12 +919,18 @@ function PeriodControl({
829
919
  period,
830
920
  from,
831
921
  to,
922
+ clearable,
923
+ embedded = false,
924
+ onClear,
832
925
  onChange,
833
926
  }: {
834
927
  periods: PeriodSpec[];
835
928
  period: string | null;
836
929
  from: string | null;
837
930
  to: string | null;
931
+ clearable: boolean;
932
+ embedded?: boolean;
933
+ onClear?: () => void;
838
934
  onChange: (next: {
839
935
  period: string | null;
840
936
  from: string | null;
@@ -878,48 +974,94 @@ function PeriodControl({
878
974
  }
879
975
  }}
880
976
  >
881
- <PopoverTrigger asChild>
882
- <Button variant="outline" data-slot="action-list-period">
883
- <CalendarIcon className="h-3.5 w-3.5" />
884
- {label}
885
- </Button>
886
- </PopoverTrigger>
887
- <PopoverContent align="start" className="w-auto p-0">
977
+ <div className="relative w-fit min-w-0 max-w-full">
978
+ <PopoverTrigger asChild>
979
+ <Button
980
+ variant={embedded ? "ghost" : "outline"}
981
+ data-slot="action-list-period"
982
+ className={cn(
983
+ "max-w-full gap-1.5 overflow-hidden bg-muted/30 px-3 hover:bg-muted/30 hover:text-foreground dark:bg-input/30 dark:hover:bg-input/30",
984
+ clearable && "pr-10",
985
+ embedded &&
986
+ "gap-1 rounded-none border-0 bg-transparent pl-0 pr-0 font-normal shadow-none hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:hover:bg-transparent",
987
+ )}
988
+ >
989
+ {embedded ? null : <CalendarIcon className="h-3.5 w-3.5" />}
990
+ <span className="min-w-0 truncate">{label}</span>
991
+ {embedded ? (
992
+ <span className="shrink-0 opacity-50">
993
+ <ChevronDown className="size-4" aria-hidden="true" />
994
+ </span>
995
+ ) : null}
996
+ </Button>
997
+ </PopoverTrigger>
998
+ {clearable ? (
999
+ <InputGroupButton
1000
+ size="icon-xs"
1001
+ aria-label="Limpar período"
1002
+ className="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1003
+ onClick={() => {
1004
+ setOpen(false);
1005
+ setShowCalendar(false);
1006
+ setDraft(undefined);
1007
+ onClear?.();
1008
+ }}
1009
+ >
1010
+ <X className="size-3.5" />
1011
+ </InputGroupButton>
1012
+ ) : null}
1013
+ </div>
1014
+ <PopoverContent
1015
+ align="start"
1016
+ className="w-auto min-w-(--radix-popover-trigger-width) p-0"
1017
+ >
888
1018
  <div className="flex">
889
- <div
1019
+ <Command
890
1020
  className={cn(
891
- "flex min-w-36 flex-col gap-0.5 p-2",
1021
+ "h-auto min-w-40 rounded-none",
892
1022
  showCalendar && "border-r border-border",
893
1023
  )}
894
1024
  >
895
- {periods.map((p) => (
896
- <Button
897
- key={p.value}
898
- size="sm"
899
- context={period === p.value ? "primary" : "neutral"}
900
- variant={period === p.value ? "solid" : "ghost"}
901
- className="justify-start"
902
- onClick={() => {
903
- onChange({ period: p.value, from: null, to: null });
904
- setOpen(false);
905
- }}
906
- >
907
- {text(p.label, p.value)}
908
- </Button>
909
- ))}
910
- <Button
911
- size="sm"
912
- context={period === "custom" ? "neutral" : "primary"}
913
- variant={period === "custom" ? "solid" : "ghost"}
914
- className="justify-start"
915
- onClick={() => {
916
- setShowCalendar(true);
917
- setDraft(undefined);
918
- }}
919
- >
920
- Personalizado
921
- </Button>
922
- </div>
1025
+ <CommandList className="max-h-none">
1026
+ <CommandGroup>
1027
+ {periods.map((p) => (
1028
+ <CommandItem
1029
+ key={p.value}
1030
+ value={text(p.label, p.value)}
1031
+ onSelect={() => {
1032
+ onChange({ period: p.value, from: null, to: null });
1033
+ setOpen(false);
1034
+ }}
1035
+ >
1036
+ <span className="min-w-0 flex-1 truncate">
1037
+ {text(p.label, p.value)}
1038
+ </span>
1039
+ <Check
1040
+ className={cn(
1041
+ "ml-auto size-4",
1042
+ period === p.value ? "opacity-100" : "opacity-0",
1043
+ )}
1044
+ />
1045
+ </CommandItem>
1046
+ ))}
1047
+ <CommandItem
1048
+ value="Personalizado"
1049
+ onSelect={() => {
1050
+ setShowCalendar(true);
1051
+ setDraft(undefined);
1052
+ }}
1053
+ >
1054
+ <span className="min-w-0 flex-1 truncate">Personalizado</span>
1055
+ <Check
1056
+ className={cn(
1057
+ "ml-auto size-4",
1058
+ period === "custom" ? "opacity-100" : "opacity-0",
1059
+ )}
1060
+ />
1061
+ </CommandItem>
1062
+ </CommandGroup>
1063
+ </CommandList>
1064
+ </Command>
923
1065
  {showCalendar && (
924
1066
  <Calendar
925
1067
  mode="range"
@@ -960,6 +1102,9 @@ function AdvancedFiltersDialog({
960
1102
  values,
961
1103
  onApply,
962
1104
  filterOptions,
1105
+ filterDefaults,
1106
+ filterIcons,
1107
+ sectionIcons,
963
1108
  cascade,
964
1109
  columns = 1,
965
1110
  }: {
@@ -969,6 +1114,9 @@ function AdvancedFiltersDialog({
969
1114
  values: Record<string, unknown>;
970
1115
  onApply: (next: Record<string, unknown>) => void;
971
1116
  filterOptions?: Record<string, SelectOption[]> | undefined;
1117
+ filterDefaults?: Record<string, string> | undefined;
1118
+ filterIcons?: Record<string, ReactNode> | undefined;
1119
+ sectionIcons?: Record<string, ReactNode> | undefined;
972
1120
  columns?: 1 | 2 | 3;
973
1121
  /** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
974
1122
  cascade: (
@@ -1015,13 +1163,19 @@ function AdvancedFiltersDialog({
1015
1163
  className="w-full"
1016
1164
  onChange={(v) => setDraft((d) => cascade(name, { ...d, [name]: v }))}
1017
1165
  options={optionsFor(name, spec, filterOptions)}
1166
+ defaultValue={filterDefaults?.[name]}
1167
+ icon={filterIcons?.[name]}
1018
1168
  />
1019
1169
  </div>
1020
1170
  );
1021
1171
 
1022
1172
  const clear = (): void => {
1023
1173
  const cleared = { ...draft };
1024
- for (const [name] of specs) delete cleared[name];
1174
+ for (const [name] of specs) {
1175
+ const defaultValue = filterDefaults?.[name];
1176
+ if (defaultValue === undefined) delete cleared[name];
1177
+ else cleared[name] = defaultValue;
1178
+ }
1025
1179
  onApply(cleared);
1026
1180
  onOpenChange(false);
1027
1181
  };
@@ -1038,25 +1192,30 @@ function AdvancedFiltersDialog({
1038
1192
  <DialogTitle>Filtros</DialogTitle>
1039
1193
  </DialogHeader>
1040
1194
  <DialogBody className="space-y-4">
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
- : (
1195
+ {sectioned ? (
1196
+ sections.map(([section, entries]) => (
1197
+ <FieldSet key={section} className="gap-0">
1198
+ <FieldLegend variant="section" icon={sectionIcons?.[section]}>
1199
+ {section}
1200
+ </FieldLegend>
1051
1201
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
1052
- {specs.map(field)}
1202
+ {entries.map(field)}
1053
1203
  </FieldGroup>
1054
- )}
1204
+ </FieldSet>
1205
+ ))
1206
+ ) : (
1207
+ <FieldGroup className={cn("grid gap-4", gridColumns)}>
1208
+ {specs.map(field)}
1209
+ </FieldGroup>
1210
+ )}
1055
1211
  </DialogBody>
1056
- <DialogFooter>
1057
- <ButtonGroup mode="spaced" distribution="equal">
1058
- <Button variant="outline" onClick={clear}>
1059
- Limpar
1212
+ <DialogFooter className="justify-between">
1213
+ <Button variant="ghost" icon={<Eraser />} onClick={clear}>
1214
+ Limpar
1215
+ </Button>
1216
+ <ButtonGroup mode="spaced">
1217
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
1218
+ Cancelar
1060
1219
  </Button>
1061
1220
  <Button onClick={apply}>Aplicar</Button>
1062
1221
  </ButtonGroup>
@@ -1072,9 +1231,13 @@ export function ActionFilterBar({
1072
1231
  state,
1073
1232
  onStateChange,
1074
1233
  filterOptions,
1234
+ filterDefaults,
1235
+ filterIcons,
1075
1236
  advancedFilters,
1076
1237
  onRefresh,
1077
1238
  refreshing = false,
1239
+ periodPosition = "before",
1240
+ period,
1078
1241
  controls,
1079
1242
  actions,
1080
1243
  }: ActionFilterBarProps): React.ReactElement {
@@ -1093,9 +1256,11 @@ export function ActionFilterBar({
1093
1256
  periods[0]?.value ??
1094
1257
  null;
1095
1258
  const activePeriod = state.period ?? defaultPeriod;
1259
+ const periodBasisEntry =
1260
+ periods.length > 0 && period !== undefined
1261
+ ? inlineSpecs.find(([name]) => name === period.basisFilter)
1262
+ : undefined;
1096
1263
  const [advancedOpen, setAdvancedOpen] = useState(false);
1097
- const toolbarRowRef = useRef<HTMLDivElement | null>(null);
1098
- const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY);
1099
1264
 
1100
1265
  const cascadeClear = (
1101
1266
  changed: string,
@@ -1113,41 +1278,8 @@ export function ActionFilterBar({
1113
1278
  return next;
1114
1279
  };
1115
1280
 
1116
- useLayoutEffect(() => {
1117
- const row = toolbarRowRef.current;
1118
- if (row === null) return;
1119
- const overflowing = (): boolean =>
1120
- row.scrollWidth > row.clientWidth + 1 ||
1121
- Array.from(row.children).some(
1122
- (child) => child.scrollWidth > child.clientWidth + 1,
1123
- );
1124
- const compute = (): void => {
1125
- row.classList.remove("flex-wrap");
1126
- const nodes = Array.from(
1127
- row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'),
1128
- );
1129
- for (const node of nodes) node.style.removeProperty("display");
1130
- let count = nodes.length;
1131
- while (count > 0 && overflowing()) {
1132
- count -= 1;
1133
- nodes[count]?.style.setProperty("display", "none");
1134
- }
1135
- if (count === 0 && overflowing()) row.classList.add("flex-wrap");
1136
- setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count);
1137
- };
1138
- compute();
1139
- const observer = new ResizeObserver(compute);
1140
- observer.observe(row);
1141
- return () => observer.disconnect();
1142
- }, [inlineSpecs.length, state.filters, inlineFit]);
1143
-
1144
- const overflowSpecs = Number.isFinite(inlineFit)
1145
- ? inlineSpecs.slice(inlineFit)
1146
- : [];
1147
1281
  const modalSpecs = visibleFilterSpecs.filter(
1148
- ([name, spec]) =>
1149
- filterPlacement(spec) === "advanced" ||
1150
- overflowSpecs.some(([overflowName]) => overflowName === name),
1282
+ ([, spec]) => filterPlacement(spec) === "advanced",
1151
1283
  );
1152
1284
  const setFilter = (name: string, value: unknown): void => {
1153
1285
  onStateChange({
@@ -1156,7 +1288,10 @@ export function ActionFilterBar({
1156
1288
  });
1157
1289
  };
1158
1290
  const chips = modalSpecs
1159
- .filter(([name]) => !isEmptyValue(state.filters[name]))
1291
+ .filter(([name]) => {
1292
+ const value = state.filters[name] ?? filterDefaults?.[name] ?? "";
1293
+ return !isEmptyValue(value) && value !== (filterDefaults?.[name] ?? "");
1294
+ })
1160
1295
  .map(([name, spec]) => {
1161
1296
  const raw = state.filters[name];
1162
1297
  const fromProps = optionsFor(name, spec, filterOptions);
@@ -1170,76 +1305,227 @@ export function ActionFilterBar({
1170
1305
  value: Array.isArray(raw) ? raw.map(display).join(", ") : display(raw),
1171
1306
  };
1172
1307
  });
1173
- const searchFirst =
1174
- hasSearch && periods.length === 0 && visibleFilterSpecs.length === 0;
1308
+ const inlineControlCount =
1309
+ inlineSpecs.filter(([name]) => name !== periodBasisEntry?.[0]).length +
1310
+ (periods.length > 0 ? 1 : 0) +
1311
+ (hasSearch ? 1 : 0);
1312
+ const showInlineLabels = inlineControlCount > 1;
1313
+ const searchAtActionsEnd =
1314
+ hasSearch && (inlineSpecs.length > 0 || periods.length > 0);
1315
+ const periodAfterFilter =
1316
+ typeof periodPosition === "object" ? periodPosition.afterFilter : null;
1317
+ const anchoredPeriodExists = inlineSpecs.some(
1318
+ ([name]) => name === periodAfterFilter,
1319
+ );
1320
+ const periodField =
1321
+ periods.length > 0 ? (
1322
+ <PeriodControl
1323
+ periods={periods}
1324
+ period={activePeriod}
1325
+ from={state.from}
1326
+ to={state.to}
1327
+ clearable={state.period !== null}
1328
+ onClear={() =>
1329
+ onStateChange({
1330
+ ...state,
1331
+ period: null,
1332
+ from: null,
1333
+ to: null,
1334
+ })
1335
+ }
1336
+ onChange={(next) =>
1337
+ onStateChange({
1338
+ ...state,
1339
+ ...next,
1340
+ period: next.period === defaultPeriod ? null : next.period,
1341
+ })
1342
+ }
1343
+ />
1344
+ ) : null;
1345
+ const periodControl =
1346
+ periodField === null || periodBasisEntry !== undefined ? null : (
1347
+ <Labeled
1348
+ label="Período"
1349
+ showLabel={showInlineLabels}
1350
+ className="max-w-80"
1351
+ >
1352
+ {periodField}
1353
+ </Labeled>
1354
+ );
1355
+ const periodBasisDefault =
1356
+ periodBasisEntry === undefined
1357
+ ? ""
1358
+ : (filterDefaults?.[periodBasisEntry[0]] ?? "");
1359
+ const periodBasisValue =
1360
+ periodBasisEntry === undefined
1361
+ ? ""
1362
+ : ((state.filters[periodBasisEntry[0]] as string | undefined) ??
1363
+ periodBasisDefault);
1364
+ const periodGroupClearable =
1365
+ periodBasisEntry !== undefined &&
1366
+ (state.period !== null || periodBasisValue !== periodBasisDefault);
1175
1367
  const searchBox = hasSearch ? (
1176
- <ClearableInput
1177
- className="w-64 min-w-36"
1178
- leading={<Search />}
1179
- applied={state.q}
1180
- placeholder={text(action.text?.placeholder, "Buscar…")}
1181
- onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
1182
- />
1368
+ <Labeled
1369
+ label="Busca"
1370
+ slot="action-filter-search"
1371
+ showLabel={showInlineLabels}
1372
+ className="w-[clamp(8rem,20vw,16rem)] max-w-64 basis-[clamp(8rem,20vw,16rem)]"
1373
+ >
1374
+ <ClearableInput
1375
+ className="w-full min-w-0"
1376
+ shape="pill"
1377
+ leading={<Search />}
1378
+ applied={state.q}
1379
+ placeholder={text(action.text?.placeholder, "Buscar…")}
1380
+ onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
1381
+ />
1382
+ </Labeled>
1183
1383
  ) : null;
1184
1384
 
1185
1385
  return (
1186
1386
  <TooltipProvider>
1187
1387
  <div data-slot="action-filter-bar" className="space-y-2">
1188
- <div ref={toolbarRowRef} className="flex items-end gap-3">
1189
- {searchFirst && searchBox}
1190
- {periods.length > 0 && (
1191
- <Labeled label="Período">
1192
- <PeriodControl
1193
- periods={periods}
1194
- period={activePeriod}
1195
- from={state.from}
1196
- to={state.to}
1197
- onChange={(next) =>
1198
- onStateChange({
1199
- ...state,
1200
- ...next,
1201
- period: next.period === defaultPeriod ? null : next.period,
1202
- })
1203
- }
1204
- />
1205
- </Labeled>
1206
- )}
1207
- {inlineSpecs.map(([name, spec]) => (
1208
- <Labeled
1209
- key={name}
1210
- label={text(spec.label, name)}
1211
- slot="action-filter-inline"
1212
- >
1213
- <FilterField
1214
- name={name}
1215
- spec={spec}
1216
- value={state.filters[name]}
1217
- allValues={state.filters}
1218
- onChange={(value) => setFilter(name, value)}
1219
- options={optionsFor(name, spec, filterOptions)}
1220
- />
1221
- </Labeled>
1222
- ))}
1223
- {modalSpecs.length > 0 && (
1224
- <Tooltip>
1225
- <TooltipTrigger asChild>
1226
- <Button
1227
- variant="ghost"
1228
- size="icon"
1229
- className="shrink-0"
1230
- aria-label="Filtros"
1231
- onClick={() => setAdvancedOpen(true)}
1388
+ <div className="flex min-w-0 items-end gap-3">
1389
+ {periodPosition === "before" && periodControl}
1390
+ {inlineSpecs.map(([name, spec]) => {
1391
+ if (name === periodBasisEntry?.[0]) {
1392
+ return (
1393
+ <Labeled
1394
+ key={name}
1395
+ label="Período"
1396
+ showLabel={showInlineLabels}
1397
+ className="max-w-80"
1232
1398
  >
1233
- <Filter className="h-3.5 w-3.5" />
1234
- </Button>
1235
- </TooltipTrigger>
1236
- <TooltipContent>Filtros</TooltipContent>
1237
- </Tooltip>
1238
- )}
1239
- <div className="ml-auto flex min-w-0 items-center gap-2">
1240
- {!searchFirst && searchBox}
1241
- {(onRefresh !== undefined || controls !== undefined) && (
1399
+ <div
1400
+ data-slot="action-filter-period-group"
1401
+ className={cn(
1402
+ "flex min-h-9 w-fit min-w-0 max-w-full items-center rounded-md border border-input bg-muted/30 text-foreground transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[0.1875rem] focus-within:ring-ring/50 dark:bg-input/30",
1403
+ !periodGroupClearable && "pr-3",
1404
+ )}
1405
+ >
1406
+ <CalendarIcon
1407
+ className="ml-3 size-3.5 shrink-0 text-muted-foreground"
1408
+ aria-hidden="true"
1409
+ />
1410
+ <FilterField
1411
+ name={name}
1412
+ spec={spec}
1413
+ value={state.filters[name]}
1414
+ allValues={state.filters}
1415
+ onChange={(value) => setFilter(name, value)}
1416
+ options={optionsFor(name, spec, filterOptions)}
1417
+ defaultValue={filterDefaults?.[name]}
1418
+ embedded
1419
+ className="w-fit min-w-0 max-w-80 [&_[data-slot=select-control]]:gap-1 [&_[data-slot=select-control]]:rounded-none [&_[data-slot=select-control]]:bg-transparent [&_[data-slot=select-control]]:pl-2 [&_[data-slot=select-control]]:pr-0 [&_[data-slot=select-control]]:font-normal [&_[data-slot=select-control]]:text-foreground [&_[data-slot=select-control]]:shadow-none [&_[data-slot=select-control]:hover]:bg-transparent [&_[data-slot=select-control]:focus-within]:ring-0 [&_[data-slot=select-control]:focus-within]:ring-offset-0"
1420
+ />
1421
+ <Separator
1422
+ data-slot="action-filter-period-separator"
1423
+ orientation="vertical"
1424
+ className="ml-1.5 mr-2.5 h-4 shrink-0"
1425
+ />
1426
+ <PeriodControl
1427
+ periods={periods}
1428
+ period={activePeriod}
1429
+ from={state.from}
1430
+ to={state.to}
1431
+ clearable={false}
1432
+ embedded
1433
+ onChange={(next) =>
1434
+ onStateChange({
1435
+ ...state,
1436
+ ...next,
1437
+ period:
1438
+ next.period === defaultPeriod ? null : next.period,
1439
+ })
1440
+ }
1441
+ />
1442
+ {periodGroupClearable ? (
1443
+ <InputGroupButton
1444
+ size="icon-xs"
1445
+ aria-label="Limpar período"
1446
+ className="ml-0.5 mr-1.5 shrink-0 text-muted-foreground hover:text-foreground"
1447
+ onClick={() => {
1448
+ const filters = { ...state.filters };
1449
+ if (periodBasisDefault === "") delete filters[name];
1450
+ else filters[name] = periodBasisDefault;
1451
+ onStateChange({
1452
+ ...state,
1453
+ filters: cascadeClear(name, filters),
1454
+ period: null,
1455
+ from: null,
1456
+ to: null,
1457
+ });
1458
+ }}
1459
+ >
1460
+ <X className="size-3.5" />
1461
+ </InputGroupButton>
1462
+ ) : null}
1463
+ </div>
1464
+ </Labeled>
1465
+ );
1466
+ }
1467
+ return (
1468
+ <Fragment key={name}>
1469
+ <Labeled
1470
+ label={text(spec.label, name)}
1471
+ slot="action-filter-inline"
1472
+ showLabel={showInlineLabels}
1473
+ className="max-w-80"
1474
+ >
1475
+ <FilterField
1476
+ name={name}
1477
+ spec={spec}
1478
+ value={state.filters[name]}
1479
+ allValues={state.filters}
1480
+ onChange={(value) => setFilter(name, value)}
1481
+ options={optionsFor(name, spec, filterOptions)}
1482
+ defaultValue={filterDefaults?.[name]}
1483
+ icon={filterIcons?.[name]}
1484
+ />
1485
+ </Labeled>
1486
+ {periodAfterFilter === name && periodControl}
1487
+ </Fragment>
1488
+ );
1489
+ })}
1490
+ {(periodPosition === "after" ||
1491
+ (periodAfterFilter !== null && !anchoredPeriodExists)) &&
1492
+ periodControl}
1493
+ {!searchAtActionsEnd && searchBox}
1494
+ <div
1495
+ data-slot="action-filter-actions"
1496
+ className="ml-auto flex min-w-0 items-end gap-2"
1497
+ >
1498
+ {searchAtActionsEnd && searchBox}
1499
+ {(modalSpecs.length > 0 ||
1500
+ onRefresh !== undefined ||
1501
+ controls !== undefined) && (
1242
1502
  <ButtonGroup mode="spaced">
1503
+ {modalSpecs.length > 0 && (
1504
+ <Tooltip>
1505
+ <TooltipTrigger asChild>
1506
+ <Button
1507
+ variant="ghost"
1508
+ size="icon"
1509
+ className="relative shrink-0"
1510
+ aria-label="Filtros"
1511
+ onClick={() => setAdvancedOpen(true)}
1512
+ >
1513
+ <Filter className="h-3.5 w-3.5" />
1514
+ {chips.length > 0 && (
1515
+ <Dot
1516
+ context="primary"
1517
+ className="absolute top-1 right-1 ring-2 ring-background"
1518
+ />
1519
+ )}
1520
+ </Button>
1521
+ </TooltipTrigger>
1522
+ <TooltipContent>
1523
+ {chips.length === 0
1524
+ ? "Filtros"
1525
+ : `${chips.length} ${chips.length === 1 ? "filtro aplicado" : "filtros aplicados"}`}
1526
+ </TooltipContent>
1527
+ </Tooltip>
1528
+ )}
1243
1529
  {onRefresh !== undefined && (
1244
1530
  <Tooltip>
1245
1531
  <TooltipTrigger asChild>
@@ -1307,7 +1593,10 @@ export function ActionFilterBar({
1307
1593
  cascade={cascadeClear}
1308
1594
  onApply={(filters) => onStateChange({ ...state, filters })}
1309
1595
  filterOptions={filterOptions}
1596
+ filterDefaults={filterDefaults}
1597
+ filterIcons={filterIcons}
1310
1598
  columns={advancedFilters?.columns}
1599
+ sectionIcons={advancedFilters?.sectionIcons}
1311
1600
  />
1312
1601
  )}
1313
1602
  </div>
@@ -1347,11 +1636,16 @@ function RowActions({ children }: { children: ReactNode }) {
1347
1636
  export function ActionList<TInput, TItem>({
1348
1637
  action,
1349
1638
  input,
1639
+ layout = "flow",
1350
1640
  toolbarActions,
1351
1641
  columns,
1352
1642
  cells,
1353
1643
  filterOptions,
1644
+ filterDefaults,
1645
+ filterIcons,
1354
1646
  advancedFilters,
1647
+ periodPosition,
1648
+ period,
1355
1649
  views,
1356
1650
  batch,
1357
1651
  rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
@@ -1434,7 +1728,10 @@ export function ActionList<TInput, TItem>({
1434
1728
  // — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
1435
1729
  const effectiveInput = useMemo(() => {
1436
1730
  const filled: Record<string, unknown> = {};
1437
- for (const [name, v] of Object.entries(state.filters)) {
1731
+ for (const [name, v] of Object.entries({
1732
+ ...filterDefaults,
1733
+ ...state.filters,
1734
+ })) {
1438
1735
  if (isEmptyValue(v)) continue;
1439
1736
  const filter = action.filters?.[name];
1440
1737
  if (
@@ -1473,7 +1770,7 @@ export function ActionList<TInput, TItem>({
1473
1770
  limit: state.limit ?? pageSize,
1474
1771
  page: state.page,
1475
1772
  } as TInput;
1476
- }, [input, state, pageSize]);
1773
+ }, [input, state, pageSize, filterDefaults]);
1477
1774
 
1478
1775
  const inputKey = JSON.stringify(effectiveInput);
1479
1776
  const { items, total, isLoading, isFetching, isError, error, refetch } =
@@ -1617,7 +1914,11 @@ export function ActionList<TInput, TItem>({
1617
1914
  state={state}
1618
1915
  onStateChange={(next) => setStateResetPage({ ...state, ...next })}
1619
1916
  filterOptions={filterOptions}
1917
+ filterDefaults={filterDefaults}
1918
+ filterIcons={filterIcons}
1620
1919
  advancedFilters={advancedFilters}
1920
+ periodPosition={periodPosition}
1921
+ period={period}
1621
1922
  onRefresh={() => refetch()}
1622
1923
  refreshing={isLoading}
1623
1924
  controls={
@@ -1751,6 +2052,55 @@ export function ActionList<TInput, TItem>({
1751
2052
  // — Estados de carga —
1752
2053
  const busy = isFetching || extraLoading === true;
1753
2054
  const isEmpty = empty !== undefined ? empty(items) : items.length === 0;
2055
+ const initialLoading =
2056
+ busy && items.length === 0 && !(empty !== undefined && !isEmpty);
2057
+ const tableFillsAvailableHeight =
2058
+ layout === "fill" && children === undefined && activeView === "table";
2059
+ const tableStateActive =
2060
+ tableFillsAvailableHeight && (initialLoading || isError || isEmpty);
2061
+ const tableColumnCount = Math.max(
2062
+ 1,
2063
+ derived.length +
2064
+ (hasSelection ? 1 : 0) +
2065
+ (rowActions !== undefined ? 1 : 0),
2066
+ );
2067
+ const tableRows = items.map((item, idx) => (
2068
+ <TableRow
2069
+ // eslint-disable-next-line react/no-array-index-key
2070
+ key={idx}
2071
+ className={onRowClick !== undefined ? "cursor-pointer" : undefined}
2072
+ onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
2073
+ >
2074
+ {hasSelection && (
2075
+ <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
2076
+ <Checkbox
2077
+ aria-label="Selecionar linha"
2078
+ disabled={!selectable(item)}
2079
+ checked={selected.has(rowId(item))}
2080
+ onCheckedChange={(v) => {
2081
+ const next = new Set(selected);
2082
+ if (v === true) next.add(rowId(item));
2083
+ else next.delete(rowId(item));
2084
+ setSelected(next);
2085
+ }}
2086
+ />
2087
+ </TableCell>
2088
+ )}
2089
+ {derived.map((col) => (
2090
+ <TableCell key={col.key} className={col.className}>
2091
+ {col.cell(item)}
2092
+ </TableCell>
2093
+ ))}
2094
+ {rowActions !== undefined && (
2095
+ <TableCell
2096
+ className="w-px whitespace-nowrap text-right"
2097
+ onClick={(e) => e.stopPropagation()}
2098
+ >
2099
+ <RowActions>{rowActions(item)}</RowActions>
2100
+ </TableCell>
2101
+ )}
2102
+ </TableRow>
2103
+ ));
1754
2104
  let content: ReactNode;
1755
2105
  if (children !== undefined) {
1756
2106
  // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
@@ -1760,8 +2110,22 @@ export function ActionList<TInput, TItem>({
1760
2110
  content = views[activeView].render(items, () => refetch());
1761
2111
  } else {
1762
2112
  content = (
1763
- <Table variant="framed">
1764
- <TableHeader>
2113
+ <Table
2114
+ variant="framed"
2115
+ className={tableStateActive ? "h-full" : undefined}
2116
+ containerClassName={
2117
+ tableFillsAvailableHeight
2118
+ ? "absolute inset-0 overflow-auto"
2119
+ : undefined
2120
+ }
2121
+ >
2122
+ <TableHeader
2123
+ className={
2124
+ tableFillsAvailableHeight
2125
+ ? "sticky top-0 z-10 bg-muted/30 dark:bg-input/30"
2126
+ : undefined
2127
+ }
2128
+ >
1765
2129
  <TableRow>
1766
2130
  {hasSelection && (
1767
2131
  <TableHead className="w-px">
@@ -1808,58 +2172,38 @@ export function ActionList<TInput, TItem>({
1808
2172
  {rowActions !== undefined && <TableHead className="w-px" />}
1809
2173
  </TableRow>
1810
2174
  </TableHeader>
1811
- <TableBody>
1812
- {items.map((item, idx) => (
1813
- <TableRow
1814
- // eslint-disable-next-line react/no-array-index-key
1815
- key={idx}
1816
- className={
1817
- onRowClick !== undefined ? "cursor-pointer" : undefined
1818
- }
1819
- onClick={
1820
- onRowClick !== undefined ? () => onRowClick(item) : undefined
1821
- }
2175
+ <TableBody
2176
+ className={
2177
+ tableStateActive
2178
+ ? "h-full [&_[data-slot=data-state]]:h-full"
2179
+ : undefined
2180
+ }
2181
+ >
2182
+ {tableFillsAvailableHeight ? (
2183
+ <DataState
2184
+ loading={initialLoading}
2185
+ error={isError ? error : null}
2186
+ empty={isEmpty}
2187
+ emptyMessage={emptyMessage}
2188
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
2189
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
2190
+ onRetry={() => refetch()}
2191
+ colSpan={tableColumnCount}
1822
2192
  >
1823
- {hasSelection && (
1824
- <TableCell
1825
- className="w-px"
1826
- onClick={(e) => e.stopPropagation()}
1827
- >
1828
- <Checkbox
1829
- aria-label="Selecionar linha"
1830
- disabled={!selectable(item)}
1831
- checked={selected.has(rowId(item))}
1832
- onCheckedChange={(v) => {
1833
- const next = new Set(selected);
1834
- if (v === true) next.add(rowId(item));
1835
- else next.delete(rowId(item));
1836
- setSelected(next);
1837
- }}
1838
- />
1839
- </TableCell>
1840
- )}
1841
- {derived.map((col) => (
1842
- <TableCell key={col.key} className={col.className}>
1843
- {col.cell(item)}
1844
- </TableCell>
1845
- ))}
1846
- {rowActions !== undefined && (
1847
- <TableCell
1848
- className="w-px whitespace-nowrap text-right"
1849
- onClick={(e) => e.stopPropagation()}
1850
- >
1851
- <RowActions>{rowActions(item)}</RowActions>
1852
- </TableCell>
1853
- )}
1854
- </TableRow>
1855
- ))}
2193
+ {tableRows}
2194
+ </DataState>
2195
+ ) : (
2196
+ tableRows
2197
+ )}
1856
2198
  </TableBody>
1857
2199
  </Table>
1858
2200
  );
1859
2201
  }
1860
- const body = (
2202
+ const body = tableFillsAvailableHeight ? (
2203
+ content
2204
+ ) : (
1861
2205
  <DataState
1862
- loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
2206
+ loading={initialLoading}
1863
2207
  error={isError ? error : null}
1864
2208
  empty={isEmpty}
1865
2209
  emptyMessage={emptyMessage}
@@ -1982,7 +2326,15 @@ export function ActionList<TInput, TItem>({
1982
2326
 
1983
2327
  return (
1984
2328
  <TooltipProvider>
1985
- <div data-action={action.name} className="space-y-3">
2329
+ <div
2330
+ data-action={action.name}
2331
+ data-layout={layout}
2332
+ className={cn(
2333
+ layout === "fill"
2334
+ ? "flex h-full min-h-0 flex-1 flex-col gap-3"
2335
+ : "space-y-3",
2336
+ )}
2337
+ >
1986
2338
  {toolbar}
1987
2339
  {beforeContent}
1988
2340
  {selectionBar}
@@ -1990,6 +2342,9 @@ export function ActionList<TInput, TItem>({
1990
2342
  <div
1991
2343
  aria-busy={busy}
1992
2344
  className={cn(
2345
+ layout === "fill" && "relative flex min-h-0 flex-1 flex-col",
2346
+ layout === "fill" &&
2347
+ (tableFillsAvailableHeight ? "overflow-hidden" : "overflow-auto"),
1993
2348
  busy && items.length > 0 && "pointer-events-none opacity-60",
1994
2349
  )}
1995
2350
  >