@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.
@@ -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-c1911001],
15
- .lp-row-leave-active[data-v-c1911001] {
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-c1911001] {
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-c1911001],
24
- .lp-row-leave-to[data-v-c1911001] {
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-c1911001] {
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-c1911001] {
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-c1911001] {
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-c1911001],
75
- .lp-tick-cue[data-v-c1911001] {
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-c1911001],
79
- .lp-tick-cue[data-v-c1911001]:focus-within {
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-c1911001] {
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-c1911001],
95
- .lp-tick-cue[data-v-c1911001] {
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-c1911001],
102
- .lp-row-leave-active[data-v-c1911001],
103
- .lp-row-move[data-v-c1911001],
104
- .lp-tick[data-v-c1911001] {
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-DHvorDiU.css';const _hoisted_1 = {
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 hiddenValues = ref({});
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
- if (props.load) return facets.value[col.key] ?? [...new Set(sourceRows.value.map((row) => row[col.key]))];
256
- return localFacets.distinct(col.key);
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
- withMotion(() => hiddenValues.value = { ...hiddenValues.value, [key]: /* @__PURE__ */ new Set() });
273
+ writeHidden({ ...hiddenValues.value, [key]: /* @__PURE__ */ new Set() });
260
274
  }
261
275
  function setHidden(key, next) {
262
- withMotion(() => hiddenValues.value = { ...hiddenValues.value, [key]: next });
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
- withMotion(() => hiddenValues.value = {});
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: clearAllFilters
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-c1911001"]]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.74.2",
3
+ "version": "0.75.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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 { parseTableState, serializeTableState, summarizeShown } from "./tableFilter"
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 hiddenValues = ref<Record<string, Set<unknown>>>({})
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
- if (props.load) return facets.value[col.key] ?? [...new Set(sourceRows.value.map((row) => row[col.key]))]
524
- return localFacets.distinct(col.key)
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
- withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }))
598
+ writeHidden({ ...hiddenValues.value, [key]: new Set() })
544
599
  }
545
600
 
546
601
  function setHidden(key: string, next: Set<unknown>) {
547
- withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: next }))
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
- withMotion(() => (hiddenValues.value = {}))
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="clearAllFilters">
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"