@leavepulse/ui 0.74.2 → 0.75.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/dist/assets/{LpTable-DHvorDiU.css → LpTable-Bv-EcQs7.css} +19 -19
- package/dist/components/LpTable.vue.d.ts +37 -0
- package/dist/components/LpTable.vue.js +34 -15
- package/dist/components/tableFilter.d.ts +28 -0
- package/dist/components/tableFilter.js +43 -0
- package/dist/composables/useTableFilterChoice.d.ts +16 -0
- package/dist/composables/useTableFilterChoice.js +34 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/src/components/LpTable.vue +86 -23
- package/src/components/tableFilter.ts +66 -0
- package/src/composables/useTableFilterChoice.ts +51 -0
- package/src/index.ts +3 -0
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
* fade before closing the gap and the whole list lurches at the end instead of
|
|
12
12
|
* gliding.
|
|
13
13
|
*/
|
|
14
|
-
.lp-row-enter-active[data-v-
|
|
15
|
-
.lp-row-leave-active[data-v-
|
|
14
|
+
.lp-row-enter-active[data-v-d2bafc29],
|
|
15
|
+
.lp-row-leave-active[data-v-d2bafc29] {
|
|
16
16
|
transition:
|
|
17
17
|
opacity var(--duration-fast) var(--ease-emphasized),
|
|
18
18
|
transform var(--duration-fast) var(--ease-emphasized);
|
|
19
19
|
}
|
|
20
|
-
.lp-row-move[data-v-
|
|
20
|
+
.lp-row-move[data-v-d2bafc29] {
|
|
21
21
|
transition: transform var(--duration-medium) var(--ease-emphasized);
|
|
22
22
|
}
|
|
23
|
-
.lp-row-enter-from[data-v-
|
|
24
|
-
.lp-row-leave-to[data-v-
|
|
23
|
+
.lp-row-enter-from[data-v-d2bafc29],
|
|
24
|
+
.lp-row-leave-to[data-v-d2bafc29] {
|
|
25
25
|
opacity: 0;
|
|
26
26
|
transform: translateX(-0.5rem);
|
|
27
27
|
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* drops out of the table layout entirely. Nothing sits below this row to be
|
|
36
36
|
* dragged around, so simply not animating its exit costs nothing.
|
|
37
37
|
*/
|
|
38
|
-
.lp-row-empty.lp-row-leave-active[data-v-
|
|
38
|
+
.lp-row-empty.lp-row-leave-active[data-v-d2bafc29] {
|
|
39
39
|
display: none;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
* once anything is selected. An earlier version collapsed the column to nothing
|
|
55
55
|
* and floated the hover box over the first cell, where it sat on the text.
|
|
56
56
|
*/
|
|
57
|
-
.lp-tick[data-v-
|
|
57
|
+
.lp-tick[data-v-d2bafc29] {
|
|
58
58
|
display: flex;
|
|
59
59
|
align-items: center;
|
|
60
60
|
max-width: 0;
|
|
@@ -66,17 +66,17 @@
|
|
|
66
66
|
padding var(--duration-medium) var(--ease-emphasized),
|
|
67
67
|
opacity var(--duration-fast) ease;
|
|
68
68
|
}
|
|
69
|
-
.lp-tick-open[data-v-
|
|
69
|
+
.lp-tick-open[data-v-d2bafc29] {
|
|
70
70
|
max-width: 3.25rem;
|
|
71
71
|
padding-inline: 1rem;
|
|
72
72
|
opacity: 1;
|
|
73
73
|
}
|
|
74
|
-
.lp-tick-hidden[data-v-
|
|
75
|
-
.lp-tick-cue[data-v-
|
|
74
|
+
.lp-tick-hidden[data-v-d2bafc29],
|
|
75
|
+
.lp-tick-cue[data-v-d2bafc29] {
|
|
76
76
|
opacity: 0;
|
|
77
77
|
}
|
|
78
|
-
tr:hover .lp-tick-cue[data-v-
|
|
79
|
-
.lp-tick-cue[data-v-
|
|
78
|
+
tr:hover .lp-tick-cue[data-v-d2bafc29],
|
|
79
|
+
.lp-tick-cue[data-v-d2bafc29]:focus-within {
|
|
80
80
|
opacity: 1;
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -84,24 +84,24 @@ tr:hover .lp-tick-cue[data-v-c1911001],
|
|
|
84
84
|
turned down the rows change without animating rather than not changing. */
|
|
85
85
|
/* Only where there is a hover to reveal it; a finger long-presses instead. */
|
|
86
86
|
@media (hover: none) {
|
|
87
|
-
tr:hover .lp-tick-cue[data-v-
|
|
87
|
+
tr:hover .lp-tick-cue[data-v-d2bafc29] {
|
|
88
88
|
opacity: 0;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/* Nor is there anything for the column's width to hold until then: on a
|
|
92
92
|
touch screen it stays closed until a long press starts selecting, and
|
|
93
93
|
eases open like the rest of the column. */
|
|
94
|
-
.lp-tick-hidden[data-v-
|
|
95
|
-
.lp-tick-cue[data-v-
|
|
94
|
+
.lp-tick-hidden[data-v-d2bafc29],
|
|
95
|
+
.lp-tick-cue[data-v-d2bafc29] {
|
|
96
96
|
max-width: 0;
|
|
97
97
|
padding-inline: 0;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
@media (prefers-reduced-motion: reduce) {
|
|
101
|
-
.lp-row-enter-active[data-v-
|
|
102
|
-
.lp-row-leave-active[data-v-
|
|
103
|
-
.lp-row-move[data-v-
|
|
104
|
-
.lp-tick[data-v-
|
|
101
|
+
.lp-row-enter-active[data-v-d2bafc29],
|
|
102
|
+
.lp-row-leave-active[data-v-d2bafc29],
|
|
103
|
+
.lp-row-move[data-v-d2bafc29],
|
|
104
|
+
.lp-tick[data-v-d2bafc29] {
|
|
105
105
|
transition: none;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CsvOptions } from "./exportData";
|
|
2
|
+
import { type TableFilters } from "./tableFilter";
|
|
2
3
|
import { type TableRowMenuResult } from "./tableRowMenu";
|
|
3
4
|
export interface TableColumn<Row> {
|
|
4
5
|
key: string;
|
|
@@ -29,6 +30,21 @@ export interface TableColumn<Row> {
|
|
|
29
30
|
* vocabulary; the menu should say what the column itself says.
|
|
30
31
|
*/
|
|
31
32
|
valueLabel?: (value: unknown) => string;
|
|
33
|
+
/**
|
|
34
|
+
* The column's whole vocabulary, when it is known apart from the rows. The
|
|
35
|
+
* funnel lists these first and then anything else the rows hold, so a value
|
|
36
|
+
* is offered even while nothing on the page has it: the rows may already be
|
|
37
|
+
* narrowed by the same column upstream (a control above the table, a server
|
|
38
|
+
* query), and a funnel built only from what arrived could not widen it again.
|
|
39
|
+
*/
|
|
40
|
+
filterValues?: readonly unknown[];
|
|
41
|
+
/**
|
|
42
|
+
* Show how many rows hold each value in the funnel (the default). Turn off
|
|
43
|
+
* when the rows are already narrowed by this column before they reach the
|
|
44
|
+
* table: the counts would then claim that the values filtered out upstream
|
|
45
|
+
* hold no rows at all.
|
|
46
|
+
*/
|
|
47
|
+
filterCounts?: boolean;
|
|
32
48
|
/** The value a CSV export writes for this column; the raw cell by default. */
|
|
33
49
|
exportValue?: (row: Row) => unknown;
|
|
34
50
|
/** Keep this column out of the show/hide list — an actions column has no
|
|
@@ -171,6 +187,23 @@ declare const __VLS_export: <T extends Record<string, unknown>>(__VLS_props: Non
|
|
|
171
187
|
/** Active sort (v-model:sort). When set, the parent owns ordering (server
|
|
172
188
|
* side); otherwise the table sorts its own rows client-side. */
|
|
173
189
|
sort?: SortState | null;
|
|
190
|
+
/**
|
|
191
|
+
* The header filters (v-model:filters): per column key, the values hidden.
|
|
192
|
+
* When set, the parent owns them, so a control above the table that
|
|
193
|
+
* filters the same column (see `useTableFilterChoice`) and the column's
|
|
194
|
+
* funnel are one state: each shows what the other picked, and the table's
|
|
195
|
+
* resets clear both. Left unset, the table keeps them itself. With
|
|
196
|
+
* `persist`, remembered filters fill only the columns the parent leaves
|
|
197
|
+
* unfiltered, and arrive through `update:filters`.
|
|
198
|
+
*/
|
|
199
|
+
filters?: TableFilters;
|
|
200
|
+
/**
|
|
201
|
+
* The rows are narrowed by filters outside the table (a search, a period).
|
|
202
|
+
* An empty table then says nothing matches rather than that there is
|
|
203
|
+
* nothing at all, and its reset button also emits `resetFilters`, for the
|
|
204
|
+
* parent to clear its own.
|
|
205
|
+
*/
|
|
206
|
+
filtered?: boolean;
|
|
174
207
|
/** Pin the header while the body scrolls. Pair with a height on the wrapper. */
|
|
175
208
|
stickyHeader?: boolean;
|
|
176
209
|
/** Per-row right-click menu. Return [] (or omit the prop) to disable it for
|
|
@@ -213,7 +246,9 @@ declare const __VLS_export: <T extends Record<string, unknown>>(__VLS_props: Non
|
|
|
213
246
|
allMatching?: boolean;
|
|
214
247
|
} & {
|
|
215
248
|
"onUpdate:selected"?: ((value: (string | number)[]) => any) | undefined;
|
|
249
|
+
onResetFilters?: (() => any) | undefined;
|
|
216
250
|
"onUpdate:sort"?: ((value: SortState | null) => any) | undefined;
|
|
251
|
+
"onUpdate:filters"?: ((value: TableFilters) => any) | undefined;
|
|
217
252
|
"onUpdate:allMatching"?: ((value: boolean) => any) | undefined;
|
|
218
253
|
onRowClick?: ((row: T) => any) | undefined;
|
|
219
254
|
}> & (typeof globalThis extends {
|
|
@@ -240,6 +275,8 @@ declare const __VLS_export: <T extends Record<string, unknown>>(__VLS_props: Non
|
|
|
240
275
|
emit: {
|
|
241
276
|
(e: "update:selected", value: (string | number)[]): void;
|
|
242
277
|
(e: "update:sort", value: SortState | null): void;
|
|
278
|
+
(e: "update:filters", value: TableFilters): void;
|
|
279
|
+
(e: "resetFilters"): void;
|
|
243
280
|
(e: "update:allMatching", value: boolean): void;
|
|
244
281
|
(e: "rowClick", row: T): void;
|
|
245
282
|
};
|
|
@@ -25,10 +25,10 @@ import { useResizeObserver } from "../composables/useResizeObserver.js";
|
|
|
25
25
|
import { compareValues } from "./tableSort.js";
|
|
26
26
|
import { _ as _sfc_main$2 } from "../chunks/LpButton-CFb44IjC.js";
|
|
27
27
|
import { toCsv, downloadFile, fileStem } from "./exportData.js";
|
|
28
|
-
import { summarizeShown, parseTableState, serializeTableState } from "./tableFilter.js";
|
|
28
|
+
import { toHiddenSets, sameHidden, summarizeShown, parseTableState, mergeRestored, serializeTableState, fromHiddenSets } from "./tableFilter.js";
|
|
29
29
|
import { resolveRowMenu } from "./tableRowMenu.js";
|
|
30
30
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
31
|
-
import '../assets/LpTable-
|
|
31
|
+
import '../assets/LpTable-Bv-EcQs7.css';const _hoisted_1 = {
|
|
32
32
|
key: 0,
|
|
33
33
|
class: "flex justify-end"
|
|
34
34
|
};
|
|
@@ -89,6 +89,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
89
89
|
selectable: { type: [Boolean, String], default: false },
|
|
90
90
|
selected: { default: () => [] },
|
|
91
91
|
sort: { default: void 0 },
|
|
92
|
+
filters: { default: void 0 },
|
|
93
|
+
filtered: { type: Boolean, default: false },
|
|
92
94
|
stickyHeader: { type: Boolean },
|
|
93
95
|
rowMenu: {},
|
|
94
96
|
sortAscLabel: {},
|
|
@@ -105,7 +107,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
105
107
|
persist: {},
|
|
106
108
|
allMatching: { type: Boolean, default: false }
|
|
107
109
|
},
|
|
108
|
-
emits: ["update:selected", "update:sort", "update:allMatching", "rowClick"],
|
|
110
|
+
emits: ["update:selected", "update:sort", "update:filters", "resetFilters", "update:allMatching", "rowClick"],
|
|
109
111
|
setup(__props, { expose: __expose, emit: __emit }) {
|
|
110
112
|
const props = __props;
|
|
111
113
|
const labelSet = useLabels(
|
|
@@ -221,7 +223,19 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
221
223
|
const height = frameEl.value?.querySelector("table")?.offsetHeight ?? 0;
|
|
222
224
|
if (height > floor.value) floor.value = height;
|
|
223
225
|
}
|
|
224
|
-
const
|
|
226
|
+
const ownHidden = ref({});
|
|
227
|
+
let lastHidden = {};
|
|
228
|
+
const hiddenValues = computed(() => {
|
|
229
|
+
const next = props.filters !== void 0 ? toHiddenSets(props.filters) : ownHidden.value;
|
|
230
|
+
if (sameHidden(next, lastHidden)) return lastHidden;
|
|
231
|
+
lastHidden = next;
|
|
232
|
+
return next;
|
|
233
|
+
});
|
|
234
|
+
function writeHidden(next) {
|
|
235
|
+
if (sameHidden(next, hiddenValues.value)) return;
|
|
236
|
+
ownHidden.value = next;
|
|
237
|
+
emit("update:filters", fromHiddenSets(next));
|
|
238
|
+
}
|
|
225
239
|
const animating = ref(false);
|
|
226
240
|
let motionTimer;
|
|
227
241
|
function withMotion(change) {
|
|
@@ -252,21 +266,23 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
252
266
|
hidden: () => hiddenValues.value
|
|
253
267
|
});
|
|
254
268
|
function valuesOf(col) {
|
|
255
|
-
|
|
256
|
-
return
|
|
269
|
+
const found = props.load ? facets.value[col.key] ?? [...new Set(sourceRows.value.map((row) => row[col.key]))] : localFacets.distinct(col.key);
|
|
270
|
+
return col.filterValues ? [.../* @__PURE__ */ new Set([...col.filterValues, ...found])] : found;
|
|
257
271
|
}
|
|
258
272
|
function showAllValues(key) {
|
|
259
|
-
|
|
273
|
+
writeHidden({ ...hiddenValues.value, [key]: /* @__PURE__ */ new Set() });
|
|
260
274
|
}
|
|
261
275
|
function setHidden(key, next) {
|
|
262
|
-
|
|
276
|
+
writeHidden({ ...hiddenValues.value, [key]: next });
|
|
263
277
|
}
|
|
278
|
+
watch(hiddenValues, () => withMotion(() => {
|
|
279
|
+
}));
|
|
264
280
|
function labelOf(col, value) {
|
|
265
281
|
return col.valueLabel?.(value) ?? String(value ?? "—");
|
|
266
282
|
}
|
|
267
283
|
const facetOptions = computed(() => new Map(props.columns.filter((col) => col.filterable).map((col) => {
|
|
268
284
|
return [col.key, computed(() => {
|
|
269
|
-
const counts = props.load ? void 0 : localFacets.counts(col.key);
|
|
285
|
+
const counts = props.load || col.filterCounts === false ? void 0 : localFacets.counts(col.key);
|
|
270
286
|
return valuesOf(col).map((value) => ({
|
|
271
287
|
value,
|
|
272
288
|
label: labelOf(col, value),
|
|
@@ -282,7 +298,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
282
298
|
})
|
|
283
299
|
);
|
|
284
300
|
function clearAllFilters() {
|
|
285
|
-
|
|
301
|
+
writeHidden({});
|
|
302
|
+
}
|
|
303
|
+
function resetFilters() {
|
|
304
|
+
clearAllFilters();
|
|
305
|
+
emit("resetFilters");
|
|
286
306
|
}
|
|
287
307
|
function filterLabels() {
|
|
288
308
|
const t = text.value;
|
|
@@ -459,9 +479,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
459
479
|
ownSort.value = state.sort;
|
|
460
480
|
emit("update:sort", state.sort);
|
|
461
481
|
}
|
|
462
|
-
if (state.hidden)
|
|
463
|
-
hiddenValues.value = Object.fromEntries(Object.entries(state.hidden).map(([k, v]) => [k, new Set(v)]));
|
|
464
|
-
}
|
|
482
|
+
if (state.hidden) writeHidden(mergeRestored(hiddenValues.value, state.hidden));
|
|
465
483
|
if (state.columns) hiddenColumns.value = new Set(state.columns);
|
|
466
484
|
}
|
|
467
485
|
}
|
|
@@ -583,6 +601,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
583
601
|
);
|
|
584
602
|
const filteredOut = computed(() => {
|
|
585
603
|
if (busy.value || failed.value || displayRows.value.length) return false;
|
|
604
|
+
if (props.filtered) return true;
|
|
586
605
|
if (props.load) return Object.values(hiddenValues.value).some((set) => set.size);
|
|
587
606
|
return props.rows.length > 0;
|
|
588
607
|
});
|
|
@@ -946,7 +965,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
946
965
|
key: 0,
|
|
947
966
|
variant: "outline",
|
|
948
967
|
size: "sm",
|
|
949
|
-
onClick:
|
|
968
|
+
onClick: resetFilters
|
|
950
969
|
}, {
|
|
951
970
|
default: withCtx(() => [
|
|
952
971
|
createTextVNode(toDisplayString(text.value.resetFilters), 1)
|
|
@@ -1024,7 +1043,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1024
1043
|
};
|
|
1025
1044
|
}
|
|
1026
1045
|
});
|
|
1027
|
-
const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
1046
|
+
const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d2bafc29"]]);
|
|
1028
1047
|
export {
|
|
1029
1048
|
LpTable as default
|
|
1030
1049
|
};
|
|
@@ -6,6 +6,34 @@ export declare function hideAllValues(values: readonly unknown[]): Set<unknown>;
|
|
|
6
6
|
export declare function invertHidden(values: readonly unknown[], hidden: ReadonlySet<unknown>): Set<unknown>;
|
|
7
7
|
/** Show these values again, leaving the rest of the filter as it was. */
|
|
8
8
|
export declare function unhideValues(hidden: ReadonlySet<unknown>, values: readonly unknown[]): Set<unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* A table's header filters as a consumer holds them (v-model:filters on
|
|
11
|
+
* LpTable): per column key, the values hidden. A column that is absent or has
|
|
12
|
+
* an empty list is not filtered. Hidden rather than shown for the same reason
|
|
13
|
+
* as inside the table: a value nobody has seen before shows up.
|
|
14
|
+
*/
|
|
15
|
+
export type TableFilters = Record<string, unknown[]>;
|
|
16
|
+
/** The hidden list that leaves only `keep` of `values` showing. */
|
|
17
|
+
export declare function showOnly(values: readonly unknown[], keep: readonly unknown[]): unknown[];
|
|
18
|
+
/** Which of `values` a column's filter still shows. */
|
|
19
|
+
export declare function shownValues(values: readonly unknown[], hidden: readonly unknown[] | undefined): unknown[];
|
|
20
|
+
/**
|
|
21
|
+
* The rows a set of header filters leaves, for the same data drawn somewhere
|
|
22
|
+
* the table is not (cards on a phone, a CSV built by the page).
|
|
23
|
+
*/
|
|
24
|
+
export declare function filterRows<Row extends Record<string, unknown>>(rows: readonly Row[], filters: TableFilters): Row[];
|
|
25
|
+
/** Sets per column, from the plain lists a consumer writes. */
|
|
26
|
+
export declare function toHiddenSets(filters: TableFilters): Record<string, Set<unknown>>;
|
|
27
|
+
/** The plain lists a consumer reads, empty columns left out. */
|
|
28
|
+
export declare function fromHiddenSets(hidden: Record<string, ReadonlySet<unknown>>): TableFilters;
|
|
29
|
+
/** Whether two filter states hide the same values, empty columns counting as absent. */
|
|
30
|
+
export declare function sameHidden(a: Record<string, ReadonlySet<unknown>>, b: Record<string, ReadonlySet<unknown>>): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Persisted filters meeting the ones a parent already set: the parent's
|
|
33
|
+
* explicit state (a deep link, a control above the table) wins per column,
|
|
34
|
+
* and what was remembered fills only the columns it leaves alone.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeRestored(current: Record<string, ReadonlySet<unknown>>, restored: Record<string, unknown[]>): Record<string, Set<unknown>>;
|
|
9
37
|
/** Lower-cased and stripped of accents, so "cafe" finds "Café". */
|
|
10
38
|
export declare function foldText(text: string): string;
|
|
11
39
|
/** Every word of the query appears somewhere in the label, in any order. */
|
|
@@ -13,6 +13,42 @@ function unhideValues(hidden, values) {
|
|
|
13
13
|
for (const v of values) next.delete(v);
|
|
14
14
|
return next;
|
|
15
15
|
}
|
|
16
|
+
function showOnly(values, keep) {
|
|
17
|
+
return [...onlyValues(values, keep)];
|
|
18
|
+
}
|
|
19
|
+
function shownValues(values, hidden) {
|
|
20
|
+
if (!hidden?.length) return [...values];
|
|
21
|
+
const out = new Set(hidden);
|
|
22
|
+
return values.filter((v) => !out.has(v));
|
|
23
|
+
}
|
|
24
|
+
function filterRows(rows, filters) {
|
|
25
|
+
const active = Object.entries(filters).filter(([, hidden]) => hidden.length).map(([key, hidden]) => [key, new Set(hidden)]);
|
|
26
|
+
if (!active.length) return [...rows];
|
|
27
|
+
return rows.filter((row) => active.every(([key, hidden]) => !hidden.has(row[key])));
|
|
28
|
+
}
|
|
29
|
+
function toHiddenSets(filters) {
|
|
30
|
+
return Object.fromEntries(Object.entries(filters).map(([key, values]) => [key, new Set(values)]));
|
|
31
|
+
}
|
|
32
|
+
function fromHiddenSets(hidden) {
|
|
33
|
+
return Object.fromEntries(Object.entries(hidden).filter(([, set]) => set.size).map(([key, set]) => [key, [...set]]));
|
|
34
|
+
}
|
|
35
|
+
function sameHidden(a, b) {
|
|
36
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
const x = a[key];
|
|
39
|
+
const y = b[key];
|
|
40
|
+
if ((x?.size ?? 0) !== (y?.size ?? 0)) return false;
|
|
41
|
+
if (x && y) {
|
|
42
|
+
for (const v of x) if (!y.has(v)) return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
function mergeRestored(current, restored) {
|
|
48
|
+
const out = Object.fromEntries(Object.entries(current).map(([k, v]) => [k, new Set(v)]));
|
|
49
|
+
for (const [key, values] of Object.entries(restored)) if (!out[key]?.size) out[key] = new Set(values);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
16
52
|
function foldText(text) {
|
|
17
53
|
return text.normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "").toLocaleLowerCase();
|
|
18
54
|
}
|
|
@@ -72,13 +108,20 @@ function parseTableState(raw, columnKeys) {
|
|
|
72
108
|
}
|
|
73
109
|
export {
|
|
74
110
|
countValues,
|
|
111
|
+
filterRows,
|
|
75
112
|
foldText,
|
|
113
|
+
fromHiddenSets,
|
|
76
114
|
hideAllValues,
|
|
77
115
|
invertHidden,
|
|
78
116
|
matchesQuery,
|
|
117
|
+
mergeRestored,
|
|
79
118
|
onlyValues,
|
|
80
119
|
parseTableState,
|
|
120
|
+
sameHidden,
|
|
81
121
|
serializeTableState,
|
|
122
|
+
showOnly,
|
|
123
|
+
shownValues,
|
|
82
124
|
summarizeShown,
|
|
125
|
+
toHiddenSets,
|
|
83
126
|
unhideValues
|
|
84
127
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter, type Ref, type WritableComputedRef } from "vue";
|
|
2
|
+
import { type TableFilters } from "../components/tableFilter";
|
|
3
|
+
/**
|
|
4
|
+
* One column of LpTable's header filters as a single choice, for a control
|
|
5
|
+
* above the table (LpSegmented, LpSelect, a row of stat tiles) that filters
|
|
6
|
+
* the same thing as the column's funnel. Both then read and write one state:
|
|
7
|
+
* picking a choice sets the funnel, and narrowing the funnel to a choice's
|
|
8
|
+
* values lights that choice up.
|
|
9
|
+
*
|
|
10
|
+
* `choices` maps each choice to the values it shows; `null` is the choice
|
|
11
|
+
* that shows everything. `values` is the column's vocabulary, by default every
|
|
12
|
+
* value the choices name. The ref reads `undefined` while the funnel holds a
|
|
13
|
+
* mix no choice describes, so the control shows nothing picked rather than a
|
|
14
|
+
* choice that is not what the table shows.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useTableFilterChoice<C extends string>(filters: Ref<TableFilters>, column: string, choices: Record<C, readonly unknown[] | null>, values?: MaybeRefOrGetter<readonly unknown[]>): WritableComputedRef<C | undefined>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { computed, toValue } from "vue";
|
|
2
|
+
import { showOnly, shownValues } from "../components/tableFilter.js";
|
|
3
|
+
function useTableFilterChoice(filters, column, choices, values) {
|
|
4
|
+
const entries = Object.entries(choices);
|
|
5
|
+
const vocabulary = computed(() => {
|
|
6
|
+
const own = values === void 0 ? void 0 : toValue(values);
|
|
7
|
+
return own ?? [...new Set(entries.flatMap(([, shown]) => shown ?? []))];
|
|
8
|
+
});
|
|
9
|
+
return computed({
|
|
10
|
+
get: () => {
|
|
11
|
+
const all = vocabulary.value;
|
|
12
|
+
const shown = new Set(shownValues(all, filters.value[column]));
|
|
13
|
+
for (const [choice, keep] of entries) {
|
|
14
|
+
if (keep === null) {
|
|
15
|
+
if (shown.size === all.length) return choice;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (keep.length === shown.size && keep.every((v) => shown.has(v))) return choice;
|
|
19
|
+
}
|
|
20
|
+
return void 0;
|
|
21
|
+
},
|
|
22
|
+
set: (choice) => {
|
|
23
|
+
const keep = choice === void 0 ? null : choices[choice];
|
|
24
|
+
const hidden = keep === null ? [] : showOnly(vocabulary.value, keep);
|
|
25
|
+
const next = { ...filters.value };
|
|
26
|
+
delete next[column];
|
|
27
|
+
if (hidden.length) next[column] = hidden;
|
|
28
|
+
filters.value = next;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
useTableFilterChoice
|
|
34
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -155,6 +155,9 @@ export type { SortState, TableColumn, TablePage, TableQuery } from "./components
|
|
|
155
155
|
export type { TableLabels } from "./components/LpTable.vue";
|
|
156
156
|
export type { TableExportOptions } from "./components/LpTable.vue";
|
|
157
157
|
export { compareValues } from "./components/tableSort";
|
|
158
|
+
export { filterRows, showOnly, shownValues } from "./components/tableFilter";
|
|
159
|
+
export type { TableFilters } from "./components/tableFilter";
|
|
160
|
+
export { useTableFilterChoice } from "./composables/useTableFilterChoice";
|
|
158
161
|
export { resolveRowMenu } from "./components/tableRowMenu";
|
|
159
162
|
export type { TableRowMenu, TableRowMenuResult } from "./components/tableRowMenu";
|
|
160
163
|
export { default as LpTableOfContents } from "./components/LpTableOfContents.vue";
|
package/dist/index.js
CHANGED
|
@@ -99,6 +99,8 @@ import { _ as _71 } from "./chunks/LpStepper-DpsocBNJ.js";
|
|
|
99
99
|
import { _ as _72 } from "./chunks/LpSwitch-BnXd1llc.js";
|
|
100
100
|
import { default as default12 } from "./components/LpTable.vue.js";
|
|
101
101
|
import { compareValues } from "./components/tableSort.js";
|
|
102
|
+
import { filterRows, showOnly, shownValues } from "./components/tableFilter.js";
|
|
103
|
+
import { useTableFilterChoice } from "./composables/useTableFilterChoice.js";
|
|
102
104
|
import { resolveRowMenu } from "./components/tableRowMenu.js";
|
|
103
105
|
import { _ as _73 } from "./chunks/LpTableOfContents-BN5zvpnx.js";
|
|
104
106
|
import { default as default13 } from "./components/LpTabs.vue.js";
|
|
@@ -469,6 +471,7 @@ export {
|
|
|
469
471
|
fileNameOf,
|
|
470
472
|
fileStem,
|
|
471
473
|
filterCountries,
|
|
474
|
+
filterRows,
|
|
472
475
|
findHotkeyConflicts,
|
|
473
476
|
fitChips,
|
|
474
477
|
flagEmoji,
|
|
@@ -609,6 +612,8 @@ export {
|
|
|
609
612
|
setActiveTab,
|
|
610
613
|
s as setGlassBackdrop,
|
|
611
614
|
settledCells,
|
|
615
|
+
showOnly,
|
|
616
|
+
shownValues,
|
|
612
617
|
sortNodes,
|
|
613
618
|
splitTags,
|
|
614
619
|
splitTime,
|
|
@@ -680,6 +685,7 @@ export {
|
|
|
680
685
|
useSettled,
|
|
681
686
|
useShift,
|
|
682
687
|
useSpotlight,
|
|
688
|
+
useTableFilterChoice,
|
|
683
689
|
u as useTheme,
|
|
684
690
|
u2 as useThemeStudio,
|
|
685
691
|
useTilt,
|
package/package.json
CHANGED
|
@@ -32,7 +32,16 @@ import { useResizeObserver } from "../composables/useResizeObserver"
|
|
|
32
32
|
import { compareValues } from "./tableSort"
|
|
33
33
|
import LpButton from "./LpButton.vue"
|
|
34
34
|
import { downloadFile, fileStem, toCsv, type CsvOptions } from "./exportData"
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
fromHiddenSets,
|
|
37
|
+
mergeRestored,
|
|
38
|
+
parseTableState,
|
|
39
|
+
sameHidden,
|
|
40
|
+
serializeTableState,
|
|
41
|
+
summarizeShown,
|
|
42
|
+
toHiddenSets,
|
|
43
|
+
type TableFilters,
|
|
44
|
+
} from "./tableFilter"
|
|
36
45
|
import { resolveRowMenu, type TableRowMenuResult } from "./tableRowMenu"
|
|
37
46
|
|
|
38
47
|
export interface TableColumn<Row> {
|
|
@@ -64,6 +73,21 @@ export interface TableColumn<Row> {
|
|
|
64
73
|
* vocabulary; the menu should say what the column itself says.
|
|
65
74
|
*/
|
|
66
75
|
valueLabel?: (value: unknown) => string
|
|
76
|
+
/**
|
|
77
|
+
* The column's whole vocabulary, when it is known apart from the rows. The
|
|
78
|
+
* funnel lists these first and then anything else the rows hold, so a value
|
|
79
|
+
* is offered even while nothing on the page has it: the rows may already be
|
|
80
|
+
* narrowed by the same column upstream (a control above the table, a server
|
|
81
|
+
* query), and a funnel built only from what arrived could not widen it again.
|
|
82
|
+
*/
|
|
83
|
+
filterValues?: readonly unknown[]
|
|
84
|
+
/**
|
|
85
|
+
* Show how many rows hold each value in the funnel (the default). Turn off
|
|
86
|
+
* when the rows are already narrowed by this column before they reach the
|
|
87
|
+
* table: the counts would then claim that the values filtered out upstream
|
|
88
|
+
* hold no rows at all.
|
|
89
|
+
*/
|
|
90
|
+
filterCounts?: boolean
|
|
67
91
|
/** The value a CSV export writes for this column; the raw cell by default. */
|
|
68
92
|
exportValue?: (row: Row) => unknown
|
|
69
93
|
/** Keep this column out of the show/hide list — an actions column has no
|
|
@@ -209,6 +233,23 @@ const props = withDefaults(
|
|
|
209
233
|
/** Active sort (v-model:sort). When set, the parent owns ordering (server
|
|
210
234
|
* side); otherwise the table sorts its own rows client-side. */
|
|
211
235
|
sort?: SortState | null
|
|
236
|
+
/**
|
|
237
|
+
* The header filters (v-model:filters): per column key, the values hidden.
|
|
238
|
+
* When set, the parent owns them, so a control above the table that
|
|
239
|
+
* filters the same column (see `useTableFilterChoice`) and the column's
|
|
240
|
+
* funnel are one state: each shows what the other picked, and the table's
|
|
241
|
+
* resets clear both. Left unset, the table keeps them itself. With
|
|
242
|
+
* `persist`, remembered filters fill only the columns the parent leaves
|
|
243
|
+
* unfiltered, and arrive through `update:filters`.
|
|
244
|
+
*/
|
|
245
|
+
filters?: TableFilters
|
|
246
|
+
/**
|
|
247
|
+
* The rows are narrowed by filters outside the table (a search, a period).
|
|
248
|
+
* An empty table then says nothing matches rather than that there is
|
|
249
|
+
* nothing at all, and its reset button also emits `resetFilters`, for the
|
|
250
|
+
* parent to clear its own.
|
|
251
|
+
*/
|
|
252
|
+
filtered?: boolean
|
|
212
253
|
/** Pin the header while the body scrolls. Pair with a height on the wrapper. */
|
|
213
254
|
stickyHeader?: boolean
|
|
214
255
|
/** Per-row right-click menu. Return [] (or omit the prop) to disable it for
|
|
@@ -264,6 +305,8 @@ const props = withDefaults(
|
|
|
264
305
|
// undefined, not null: null is a parent saying "unsorted", undefined is no
|
|
265
306
|
// parent at all, and only the latter lets the table keep its own sort.
|
|
266
307
|
sort: undefined,
|
|
308
|
+
filters: undefined,
|
|
309
|
+
filtered: false,
|
|
267
310
|
},
|
|
268
311
|
)
|
|
269
312
|
|
|
@@ -337,6 +380,9 @@ const scrollAttrs = computed(() => {
|
|
|
337
380
|
const emit = defineEmits<{
|
|
338
381
|
(e: "update:selected", value: RowKey[]): void
|
|
339
382
|
(e: "update:sort", value: SortState | null): void
|
|
383
|
+
(e: "update:filters", value: TableFilters): void
|
|
384
|
+
/** The empty table's reset was pressed: clear the filters kept outside it. */
|
|
385
|
+
(e: "resetFilters"): void
|
|
340
386
|
(e: "update:allMatching", value: boolean): void
|
|
341
387
|
(e: "rowClick", row: T): void
|
|
342
388
|
}>()
|
|
@@ -474,7 +520,25 @@ async function holdHeight() {
|
|
|
474
520
|
* being invisible until someone notices the list has an unticked entry they
|
|
475
521
|
* never saw.
|
|
476
522
|
*/
|
|
477
|
-
const
|
|
523
|
+
const ownHidden = ref<Record<string, Set<unknown>>>({})
|
|
524
|
+
let lastHidden: Record<string, Set<unknown>> = {}
|
|
525
|
+
/*
|
|
526
|
+
* The parent's lists become sets once per real change: a parent handing back
|
|
527
|
+
* an equal state as a new object (its v-model echo, a computed that rebuilt)
|
|
528
|
+
* must not refetch a server page or replay the rows' motion.
|
|
529
|
+
*/
|
|
530
|
+
const hiddenValues = computed<Record<string, Set<unknown>>>(() => {
|
|
531
|
+
const next = props.filters !== undefined ? toHiddenSets(props.filters) : ownHidden.value
|
|
532
|
+
if (sameHidden(next, lastHidden)) return lastHidden
|
|
533
|
+
lastHidden = next
|
|
534
|
+
return next
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
function writeHidden(next: Record<string, Set<unknown>>) {
|
|
538
|
+
if (sameHidden(next, hiddenValues.value)) return
|
|
539
|
+
ownHidden.value = next
|
|
540
|
+
emit("update:filters", fromHiddenSets(next))
|
|
541
|
+
}
|
|
478
542
|
|
|
479
543
|
/*
|
|
480
544
|
* Rows animate only when the table itself reshuffles them — a header filter or
|
|
@@ -520,33 +584,28 @@ const localFacets = useTableFacets({
|
|
|
520
584
|
})
|
|
521
585
|
|
|
522
586
|
function valuesOf(col: TableColumn<T>): unknown[] {
|
|
523
|
-
|
|
524
|
-
|
|
587
|
+
const found = props.load
|
|
588
|
+
? facets.value[col.key] ?? [...new Set(sourceRows.value.map((row) => row[col.key]))]
|
|
589
|
+
: localFacets.distinct(col.key)
|
|
590
|
+
return col.filterValues ? [...new Set([...col.filterValues, ...found])] : found
|
|
525
591
|
}
|
|
526
592
|
|
|
527
593
|
function isValueHidden(key: string, value: unknown): boolean {
|
|
528
594
|
return hiddenValues.value[key]?.has(value) ?? false
|
|
529
595
|
}
|
|
530
596
|
|
|
531
|
-
function toggleValue(key: string, value: unknown) {
|
|
532
|
-
withMotion(() => applyToggle(key, value))
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function applyToggle(key: string, value: unknown) {
|
|
536
|
-
const next = new Set(hiddenValues.value[key] ?? [])
|
|
537
|
-
if (next.has(value)) next.delete(value)
|
|
538
|
-
else next.add(value)
|
|
539
|
-
hiddenValues.value = { ...hiddenValues.value, [key]: next }
|
|
540
|
-
}
|
|
541
|
-
|
|
542
597
|
function showAllValues(key: string) {
|
|
543
|
-
|
|
598
|
+
writeHidden({ ...hiddenValues.value, [key]: new Set() })
|
|
544
599
|
}
|
|
545
600
|
|
|
546
601
|
function setHidden(key: string, next: Set<unknown>) {
|
|
547
|
-
|
|
602
|
+
writeHidden({ ...hiddenValues.value, [key]: next })
|
|
548
603
|
}
|
|
549
604
|
|
|
605
|
+
// Whoever changed the filters, the funnel or a control of the parent's, the
|
|
606
|
+
// rows they hide and reveal move the same way.
|
|
607
|
+
watch(hiddenValues, () => withMotion(() => {}))
|
|
608
|
+
|
|
550
609
|
function labelOf(col: TableColumn<T>, value: unknown): string {
|
|
551
610
|
return col.valueLabel?.(value) ?? String(value ?? "—")
|
|
552
611
|
}
|
|
@@ -558,7 +617,7 @@ function labelOf(col: TableColumn<T>, value: unknown): string {
|
|
|
558
617
|
*/
|
|
559
618
|
const facetOptions = computed(() => new Map(props.columns.filter((col) => col.filterable).map((col) => {
|
|
560
619
|
return [col.key, computed(() => {
|
|
561
|
-
const counts = props.load ? undefined : localFacets.counts(col.key)
|
|
620
|
+
const counts = props.load || col.filterCounts === false ? undefined : localFacets.counts(col.key)
|
|
562
621
|
return valuesOf(col).map((value) => ({
|
|
563
622
|
value,
|
|
564
623
|
label: labelOf(col, value),
|
|
@@ -579,7 +638,12 @@ const activeFilters = computed(() =>
|
|
|
579
638
|
)
|
|
580
639
|
|
|
581
640
|
function clearAllFilters() {
|
|
582
|
-
|
|
641
|
+
writeHidden({})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function resetFilters() {
|
|
645
|
+
clearAllFilters()
|
|
646
|
+
emit("resetFilters")
|
|
583
647
|
}
|
|
584
648
|
|
|
585
649
|
function filterLabels() {
|
|
@@ -816,9 +880,7 @@ onMounted(() => {
|
|
|
816
880
|
ownSort.value = state.sort
|
|
817
881
|
emit("update:sort", state.sort)
|
|
818
882
|
}
|
|
819
|
-
if (state.hidden)
|
|
820
|
-
hiddenValues.value = Object.fromEntries(Object.entries(state.hidden).map(([k, v]) => [k, new Set(v)]))
|
|
821
|
-
}
|
|
883
|
+
if (state.hidden) writeHidden(mergeRestored(hiddenValues.value, state.hidden))
|
|
822
884
|
if (state.columns) hiddenColumns.value = new Set(state.columns)
|
|
823
885
|
}
|
|
824
886
|
}
|
|
@@ -991,6 +1053,7 @@ const showBar = computed(
|
|
|
991
1053
|
)
|
|
992
1054
|
const filteredOut = computed(() => {
|
|
993
1055
|
if (busy.value || failed.value || displayRows.value.length) return false
|
|
1056
|
+
if (props.filtered) return true
|
|
994
1057
|
if (props.load) return Object.values(hiddenValues.value).some((set) => set.size)
|
|
995
1058
|
return props.rows.length > 0
|
|
996
1059
|
})
|
|
@@ -1364,7 +1427,7 @@ usePaletteCommands(() => {
|
|
|
1364
1427
|
:icon="failed ? 'lucide:circle-alert' : filteredOut ? 'lucide:filter-x' : emptyIcon"
|
|
1365
1428
|
:title="failed ? text.error : filteredOut ? text.noMatches : text.empty"
|
|
1366
1429
|
>
|
|
1367
|
-
<LpButton v-if="filteredOut" variant="outline" size="sm" @click="
|
|
1430
|
+
<LpButton v-if="filteredOut" variant="outline" size="sm" @click="resetFilters">
|
|
1368
1431
|
{{ text.resetFilters }}
|
|
1369
1432
|
</LpButton>
|
|
1370
1433
|
</LpEmptyState>
|
|
@@ -27,6 +27,72 @@ export function unhideValues(hidden: ReadonlySet<unknown>, values: readonly unkn
|
|
|
27
27
|
return next
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A table's header filters as a consumer holds them (v-model:filters on
|
|
32
|
+
* LpTable): per column key, the values hidden. A column that is absent or has
|
|
33
|
+
* an empty list is not filtered. Hidden rather than shown for the same reason
|
|
34
|
+
* as inside the table: a value nobody has seen before shows up.
|
|
35
|
+
*/
|
|
36
|
+
export type TableFilters = Record<string, unknown[]>
|
|
37
|
+
|
|
38
|
+
/** The hidden list that leaves only `keep` of `values` showing. */
|
|
39
|
+
export function showOnly(values: readonly unknown[], keep: readonly unknown[]): unknown[] {
|
|
40
|
+
return [...onlyValues(values, keep)]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which of `values` a column's filter still shows. */
|
|
44
|
+
export function shownValues(values: readonly unknown[], hidden: readonly unknown[] | undefined): unknown[] {
|
|
45
|
+
if (!hidden?.length) return [...values]
|
|
46
|
+
const out = new Set(hidden)
|
|
47
|
+
return values.filter((v) => !out.has(v))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The rows a set of header filters leaves, for the same data drawn somewhere
|
|
52
|
+
* the table is not (cards on a phone, a CSV built by the page).
|
|
53
|
+
*/
|
|
54
|
+
export function filterRows<Row extends Record<string, unknown>>(rows: readonly Row[], filters: TableFilters): Row[] {
|
|
55
|
+
const active = Object.entries(filters).filter(([, hidden]) => hidden.length).map(([key, hidden]) => [key, new Set(hidden)] as const)
|
|
56
|
+
if (!active.length) return [...rows]
|
|
57
|
+
return rows.filter((row) => active.every(([key, hidden]) => !hidden.has(row[key])))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sets per column, from the plain lists a consumer writes. */
|
|
61
|
+
export function toHiddenSets(filters: TableFilters): Record<string, Set<unknown>> {
|
|
62
|
+
return Object.fromEntries(Object.entries(filters).map(([key, values]) => [key, new Set(values)]))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The plain lists a consumer reads, empty columns left out. */
|
|
66
|
+
export function fromHiddenSets(hidden: Record<string, ReadonlySet<unknown>>): TableFilters {
|
|
67
|
+
return Object.fromEntries(Object.entries(hidden).filter(([, set]) => set.size).map(([key, set]) => [key, [...set]]))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether two filter states hide the same values, empty columns counting as absent. */
|
|
71
|
+
export function sameHidden(a: Record<string, ReadonlySet<unknown>>, b: Record<string, ReadonlySet<unknown>>): boolean {
|
|
72
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
const x = a[key]
|
|
75
|
+
const y = b[key]
|
|
76
|
+
if ((x?.size ?? 0) !== (y?.size ?? 0)) return false
|
|
77
|
+
if (x && y) for (const v of x) if (!y.has(v)) return false
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Persisted filters meeting the ones a parent already set: the parent's
|
|
84
|
+
* explicit state (a deep link, a control above the table) wins per column,
|
|
85
|
+
* and what was remembered fills only the columns it leaves alone.
|
|
86
|
+
*/
|
|
87
|
+
export function mergeRestored(
|
|
88
|
+
current: Record<string, ReadonlySet<unknown>>,
|
|
89
|
+
restored: Record<string, unknown[]>,
|
|
90
|
+
): Record<string, Set<unknown>> {
|
|
91
|
+
const out: Record<string, Set<unknown>> = Object.fromEntries(Object.entries(current).map(([k, v]) => [k, new Set(v)]))
|
|
92
|
+
for (const [key, values] of Object.entries(restored)) if (!out[key]?.size) out[key] = new Set(values)
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
/** Lower-cased and stripped of accents, so "cafe" finds "Café". */
|
|
31
97
|
export function foldText(text: string): string {
|
|
32
98
|
return text.normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter, type Ref, type WritableComputedRef } from "vue"
|
|
2
|
+
import { shownValues, showOnly, type TableFilters } from "../components/tableFilter"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One column of LpTable's header filters as a single choice, for a control
|
|
6
|
+
* above the table (LpSegmented, LpSelect, a row of stat tiles) that filters
|
|
7
|
+
* the same thing as the column's funnel. Both then read and write one state:
|
|
8
|
+
* picking a choice sets the funnel, and narrowing the funnel to a choice's
|
|
9
|
+
* values lights that choice up.
|
|
10
|
+
*
|
|
11
|
+
* `choices` maps each choice to the values it shows; `null` is the choice
|
|
12
|
+
* that shows everything. `values` is the column's vocabulary, by default every
|
|
13
|
+
* value the choices name. The ref reads `undefined` while the funnel holds a
|
|
14
|
+
* mix no choice describes, so the control shows nothing picked rather than a
|
|
15
|
+
* choice that is not what the table shows.
|
|
16
|
+
*/
|
|
17
|
+
export function useTableFilterChoice<C extends string>(
|
|
18
|
+
filters: Ref<TableFilters>,
|
|
19
|
+
column: string,
|
|
20
|
+
choices: Record<C, readonly unknown[] | null>,
|
|
21
|
+
values?: MaybeRefOrGetter<readonly unknown[]>,
|
|
22
|
+
): WritableComputedRef<C | undefined> {
|
|
23
|
+
const entries = Object.entries(choices) as [C, readonly unknown[] | null][]
|
|
24
|
+
const vocabulary = computed(() => {
|
|
25
|
+
const own = values === undefined ? undefined : toValue(values)
|
|
26
|
+
return own ?? [...new Set(entries.flatMap(([, shown]) => shown ?? []))]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return computed({
|
|
30
|
+
get: () => {
|
|
31
|
+
const all = vocabulary.value
|
|
32
|
+
const shown = new Set(shownValues(all, filters.value[column]))
|
|
33
|
+
for (const [choice, keep] of entries) {
|
|
34
|
+
if (keep === null) {
|
|
35
|
+
if (shown.size === all.length) return choice
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (keep.length === shown.size && keep.every((v) => shown.has(v))) return choice
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
},
|
|
42
|
+
set: (choice) => {
|
|
43
|
+
const keep = choice === undefined ? null : choices[choice]
|
|
44
|
+
const hidden = keep === null ? [] : showOnly(vocabulary.value, keep)
|
|
45
|
+
const next = { ...filters.value }
|
|
46
|
+
delete next[column]
|
|
47
|
+
if (hidden.length) next[column] = hidden
|
|
48
|
+
filters.value = next
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -220,6 +220,9 @@ export type { SortState, TableColumn, TablePage, TableQuery } from "./components
|
|
|
220
220
|
export type { TableLabels } from "./components/LpTable.vue"
|
|
221
221
|
export type { TableExportOptions } from "./components/LpTable.vue"
|
|
222
222
|
export { compareValues } from "./components/tableSort"
|
|
223
|
+
export { filterRows, showOnly, shownValues } from "./components/tableFilter"
|
|
224
|
+
export type { TableFilters } from "./components/tableFilter"
|
|
225
|
+
export { useTableFilterChoice } from "./composables/useTableFilterChoice"
|
|
223
226
|
export { resolveRowMenu } from "./components/tableRowMenu"
|
|
224
227
|
export type { TableRowMenu, TableRowMenuResult } from "./components/tableRowMenu"
|
|
225
228
|
export { default as LpTableOfContents } from "./components/LpTableOfContents.vue"
|