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.
- package/package.json +1 -1
- package/registry/components/primitives/popover/popover-context.ts +33 -29
- package/registry/components/primitives/popover/popover.tsx +138 -24
- package/registry/components/ui/copy-button/copy-button.tsx +5 -0
- package/registry/components/ui/data-table/data-table.module.css +743 -710
- package/registry/components/ui/data-table/data-table.tsx +26 -8
- package/registry/components/ui/data-table/filter-modal.tsx +86 -0
- package/registry/components/ui/data-table/types.ts +8 -0
- package/registry/components/ui/data-table/useDataTable.tsx +1 -0
- package/registry/components/ui/data-table/utils.ts +26 -1
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +11 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +500 -484
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +940 -933
- package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
- package/registry/components/ui/tabs/tabs.tsx +29 -16
- package/registry/hooks/useMediaQuery.ts +38 -0
- package/registry/registry.json +22 -2
|
@@ -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
|
-
{/*
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|