aplance-ui 1.3.0 → 1.4.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,6 +24,7 @@ import {
24
24
  } from "react";
25
25
  import clsx from "clsx";
26
26
  import { FilterBar } from "./filter-bar";
27
+ import { FilterModal } from "./filter-modal";
27
28
  import { PaginationBar } from "./pagination-bar";
28
29
  import { Loader } from "../loader/loader";
29
30
  import type {
@@ -60,6 +61,12 @@ import {
60
61
  } from "@tabler/icons-react";
61
62
  import { getScrollbarWidth } from "../../../lib/dom";
62
63
  import { Button } from "../button/button";
64
+ import { useMediaQuery } from "../../../hooks/useMediaQuery";
65
+
66
+ // Below this width the inline filter row can't fit its fields (matches the
67
+ // `--md` custom-media token in styles/media.css); the table collapses filters
68
+ // into a single "Filters" button + modal instead of stacking them full-height.
69
+ const FILTER_COMPACT_QUERY = "(max-width: 900px)";
63
70
 
64
71
  const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50];
65
72
  const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 10 };
@@ -143,6 +150,9 @@ export const DataTable = <TData extends { id: string }>({
143
150
  paginationExtra,
144
151
  className,
145
152
  }: DataTableProps<TData>) => {
153
+ // Collapse the filter row into a button + modal once it no longer fits inline.
154
+ const filtersCompact = useMediaQuery(FILTER_COMPACT_QUERY);
155
+
146
156
  // ── Table meta ──────────────────────────────────────────────────────────────
147
157
  const meta: DataTableMeta<TData> = useMemo(
148
158
  () => ({ editingRowId, updateData: onUpdateData }),
@@ -597,14 +607,22 @@ export const DataTable = <TData extends { id: string }>({
597
607
 
598
608
  return (
599
609
  <div className={clsx(s.root, className)}>
600
- {/* Filter bar - fixed height, never scrolls */}
601
- {filters.length > 0 && (
602
- <FilterBar
603
- fields={filters}
604
- values={filterValues}
605
- onChange={onFilterChange}
606
- />
607
- )}
610
+ {/* Filters - inline row on desktop; a "Filters" button + modal once the
611
+ row no longer fits (so phones aren't buried under stacked fields). */}
612
+ {filters.length > 0 &&
613
+ (filtersCompact ? (
614
+ <FilterModal
615
+ fields={filters}
616
+ values={filterValues}
617
+ onChange={onFilterChange}
618
+ />
619
+ ) : (
620
+ <FilterBar
621
+ fields={filters}
622
+ values={filterValues}
623
+ onChange={onFilterChange}
624
+ />
625
+ ))}
608
626
 
609
627
  {/* Content wrap - grows to fill remaining height.
610
628
  table_shell fills it; pagination pins to the bottom via margin-top:auto */}
@@ -0,0 +1,86 @@
1
+ import { useState } from "react";
2
+ import { IconFilter } from "@tabler/icons-react";
3
+ import {
4
+ Modal,
5
+ ModalBody,
6
+ ModalFooter,
7
+ ModalHeader,
8
+ ModalTitle,
9
+ } from "../../primitives/modal/modal";
10
+ import { Icon } from "../../primitives/icon/icon";
11
+ import { Button } from "../button/button";
12
+ import type { FilterBarProps } from "./types";
13
+ import s from "./data-table.module.css";
14
+
15
+ /**
16
+ * Small-screen counterpart to <FilterBar>. It keeps filtering live while moving
17
+ * the same field contract into a Modal bottom sheet.
18
+ */
19
+ export const FilterModal = ({ fields, values, onChange }: FilterBarProps) => {
20
+ const [isOpen, setIsOpen] = useState(false);
21
+
22
+ // Action slots (buttons/menus) render alongside the inputs; only input fields
23
+ // carry a value, so only they count toward the active badge + Clear-all reset.
24
+ const inputFields = fields.filter((field) => field.type === "field");
25
+ const activeCount = inputFields.filter(
26
+ (field) => (values[field.key] ?? "") !== "",
27
+ ).length;
28
+ const clearAll = () => {
29
+ inputFields.forEach((field) => {
30
+ if ((values[field.key] ?? "") !== "") onChange(field.key, "");
31
+ });
32
+ };
33
+
34
+ return (
35
+ <div className={s.filter_modal_bar}>
36
+ <Button
37
+ type="button"
38
+ variant="bordered"
39
+ className={s.filter_trigger}
40
+ onClick={() => setIsOpen(true)}
41
+ >
42
+ <Icon>
43
+ <IconFilter />
44
+ </Icon>
45
+ Filters
46
+ {activeCount > 0 && <span className={s.filter_count}>{activeCount}</span>}
47
+ </Button>
48
+
49
+ <Modal isOpen={isOpen} onOpenChange={setIsOpen} placement="bottom" size="md">
50
+ <ModalHeader>
51
+ <ModalTitle>Filters</ModalTitle>
52
+ </ModalHeader>
53
+ <ModalBody>
54
+ <div className={s.filter_modal_fields}>
55
+ {fields.map((field, i) => (
56
+ <div
57
+ className={s.filter_modal_field}
58
+ key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
59
+ >
60
+ {field.type === "action"
61
+ ? field.render()
62
+ : field.render({
63
+ value: values[field.key] ?? "",
64
+ onChange: (v) => onChange(field.key, v),
65
+ })}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </ModalBody>
70
+ <ModalFooter>
71
+ <Button
72
+ type="button"
73
+ variant="ghost"
74
+ onClick={clearAll}
75
+ disabled={activeCount === 0}
76
+ >
77
+ Clear all
78
+ </Button>
79
+ <Button type="button" variant="primary" onClick={() => setIsOpen(false)}>
80
+ Done
81
+ </Button>
82
+ </ModalFooter>
83
+ </Modal>
84
+ </div>
85
+ );
86
+ };
@@ -556,6 +556,8 @@ export interface PersistArgs {
556
556
  setGrouping: (next: GroupingState) => void;
557
557
  expanded: ExpandedState;
558
558
  setExpanded: (next: ExpandedState) => void;
559
+ /** Resolved default expansion - used to skip persisting it when unchanged. */
560
+ defaultExpanded: ExpandedState;
559
561
  }
560
562
 
561
563
  // ─── Snapshot / apply helpers shape ───────────────────────────────────────────
@@ -573,6 +575,12 @@ export interface SnapshotInput {
573
575
  columnSizing: ColumnSizingState;
574
576
  grouping: GroupingState;
575
577
  expanded: ExpandedState;
578
+ /**
579
+ * Resolved default expansion (the hook's `defaultExpanded ?? true`). Used to
580
+ * skip persisting `expanded` when it still matches the default - otherwise the
581
+ * default writes a noisy `?expanded=true` even when no group was touched.
582
+ */
583
+ defaultExpanded: ExpandedState;
576
584
  }
577
585
 
578
586
  /** Setters used by `applyPersistedState` to push hydrated values back into state. */
@@ -334,6 +334,7 @@ export function useDataTable<TData extends { id: string }>({
334
334
  setGrouping: setGroupingState,
335
335
  expanded,
336
336
  setExpanded: setExpandedState,
337
+ defaultExpanded: defaultExpanded ?? true,
337
338
  });
338
339
 
339
340
  // ── Derived data ────────────────────────────────────────────────────────────
@@ -147,6 +147,23 @@ export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
147
147
  const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
148
148
  e !== undefined && (e === true || Object.keys(e).length > 0);
149
149
 
150
+ /**
151
+ * Structural equality for two `ExpandedState`s (each a boolean or an
152
+ * id -> boolean record). Lets the snapshot skip persisting `expanded` while it
153
+ * still matches the table's default.
154
+ */
155
+ const expandedEquals = (
156
+ a: ExpandedState | undefined,
157
+ b: ExpandedState | undefined,
158
+ ): boolean => {
159
+ if (a === b) return true;
160
+ if (typeof a === "boolean" || typeof b === "boolean") return false;
161
+ if (!a || !b) return false;
162
+ const aKeys = Object.keys(a);
163
+ if (aKeys.length !== Object.keys(b).length) return false;
164
+ return aKeys.every((k) => a[k] === b[k]);
165
+ };
166
+
150
167
  /**
151
168
  * Build a `PersistedState` snapshot from current state, omitting empty/default
152
169
  * slices so the persisted blob (URL or storage) stays minimal.
@@ -183,7 +200,15 @@ export function snapshotPersistedState(input: SnapshotInput): PersistedState {
183
200
  out.columnSizing = input.columnSizing;
184
201
  }
185
202
  if (input.grouping.length > 0) out.grouping = input.grouping;
186
- if (isNonEmptyExpanded(input.expanded)) out.expanded = input.expanded;
203
+ // Persist expansion only when it deviates from the default - otherwise the
204
+ // default (`true` = all groups expanded) writes a noisy `?expanded=true` even
205
+ // when the user never touched a group.
206
+ if (
207
+ isNonEmptyExpanded(input.expanded) &&
208
+ !expandedEquals(input.expanded, input.defaultExpanded)
209
+ ) {
210
+ out.expanded = input.expanded;
211
+ }
187
212
 
188
213
  return out;
189
214
  }
@@ -62,6 +62,17 @@
62
62
  max-width: 100% !important;
63
63
  }
64
64
 
65
+ /* Trailing per-option meta (option.meta). The growing .option_label pushes it
66
+ to the row's end; consumers own the slot's visual styling. */
67
+ .option_meta {
68
+ flex-shrink: 0;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: var(--spacing-2);
72
+ margin-inline-start: var(--spacing-3);
73
+ white-space: nowrap;
74
+ }
75
+
65
76
  /* Trailing per-option delete affordance (option.onDelete). The growing
66
77
  .option_label pushes it to the row's end; muted until hovered, then
67
78
  error-toned. Click is stopPropagation'd in the component so it never