@leavepulse/ui 0.74.2 → 0.76.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.
Files changed (52) hide show
  1. package/README.md +44 -0
  2. package/dist/assets/{LpShortcutsDialog-BUp_BX4s.css → LpShortcutsDialog-C6uzPeMf.css} +7 -7
  3. package/dist/assets/{LpTable-DHvorDiU.css → LpTable-Bv-EcQs7.css} +19 -19
  4. package/dist/assets/LpTabs-CJ0rYQbx.css +18 -0
  5. package/dist/chunks/{LpAddressInput-Al3HT5ey.js → LpAddressInput-De_LTYM7.js} +1 -1
  6. package/dist/chunks/{LpAppShell-CpSSm5WT.js → LpAppShell-Cpy_t1U_.js} +1 -1
  7. package/dist/chunks/{LpConfirmDialog-DY2VjDji.js → LpConfirmDialog-CphJR6Q9.js} +4 -2
  8. package/dist/chunks/{LpConfirmHost-DvSZTJ4N.js → LpConfirmHost-QSJ0zl63.js} +1 -1
  9. package/dist/chunks/{LpDrawer-RRamxbLV.js → LpDrawer-DQZixOqJ.js} +13 -0
  10. package/dist/chunks/{LpModal-BnjZ2iem.js → LpModal-C0tGg_D_.js} +13 -0
  11. package/dist/chunks/{LpModalNav-DQpV2kdn.js → LpModalNav-iDDtT9c1.js} +2 -2
  12. package/dist/chunks/{LpSidebar-BoNLKgh-.js → LpSidebar-D6zO8vLm.js} +1 -1
  13. package/dist/components/LpAddressInput.vue.js +1 -1
  14. package/dist/components/LpAppShell.vue.js +1 -1
  15. package/dist/components/LpConfirmDialog.vue.d.ts +4 -0
  16. package/dist/components/LpConfirmDialog.vue.js +1 -1
  17. package/dist/components/LpConfirmHost.vue.js +1 -1
  18. package/dist/components/LpDrawer.vue.d.ts +16 -0
  19. package/dist/components/LpDrawer.vue.js +1 -1
  20. package/dist/components/LpModal.vue.d.ts +9 -0
  21. package/dist/components/LpModal.vue.js +1 -1
  22. package/dist/components/LpModalNav.vue.js +1 -1
  23. package/dist/components/LpShortcutsDialog.vue.d.ts +4 -0
  24. package/dist/components/LpShortcutsDialog.vue.js +6 -4
  25. package/dist/components/LpSidebar.vue.js +1 -1
  26. package/dist/components/LpTable.vue.d.ts +37 -0
  27. package/dist/components/LpTable.vue.js +34 -15
  28. package/dist/components/LpTabs.vue.d.ts +9 -0
  29. package/dist/components/LpTabs.vue.js +18 -4
  30. package/dist/components/tableFilter.d.ts +28 -0
  31. package/dist/components/tableFilter.js +43 -0
  32. package/dist/composables/overlayHistory.d.ts +33 -0
  33. package/dist/composables/overlayHistory.js +237 -0
  34. package/dist/composables/useOverlayHistory.d.ts +45 -0
  35. package/dist/composables/useOverlayHistory.js +84 -0
  36. package/dist/composables/useTableFilterChoice.d.ts +16 -0
  37. package/dist/composables/useTableFilterChoice.js +34 -0
  38. package/dist/index.d.ts +6 -0
  39. package/dist/index.js +18 -8
  40. package/package.json +2 -1
  41. package/src/components/LpConfirmDialog.vue +5 -1
  42. package/src/components/LpDrawer.vue +21 -0
  43. package/src/components/LpModal.vue +21 -1
  44. package/src/components/LpShortcutsDialog.vue +5 -1
  45. package/src/components/LpTable.vue +86 -23
  46. package/src/components/LpTabs.vue +26 -2
  47. package/src/components/tableFilter.ts +66 -0
  48. package/src/composables/overlayHistory.ts +381 -0
  49. package/src/composables/useOverlayHistory.ts +173 -0
  50. package/src/composables/useTableFilterChoice.ts +51 -0
  51. package/src/index.ts +6 -0
  52. package/dist/assets/LpTabs-CDchzoRL.css +0 -18
@@ -0,0 +1,84 @@
1
+ import { inject, computed, toValue, watch, onBeforeUnmount, provide, nextTick } from "vue";
2
+ import { isLive, pushEntry, dropEntry, setHistoryRouter } from "./overlayHistory.js";
3
+ const MODE = /* @__PURE__ */ Symbol("lp-overlay-history");
4
+ const SCOPE = /* @__PURE__ */ Symbol("lp-overlay-history-scope");
5
+ function provideOverlayHistory(mode = true, options = {}) {
6
+ provide(MODE, mode);
7
+ if (options.router) setHistoryRouter(options.router);
8
+ }
9
+ function useOverlayHistory(options) {
10
+ const inherited = inject(MODE, false);
11
+ const mode = computed(() => options.mode() ?? toValue(inherited));
12
+ const entry = {
13
+ alive: true,
14
+ back: () => {
15
+ void options.close().then(
16
+ () => nextTick(() => {
17
+ if (entry.alive && options.open() && !isLive(entry)) pushEntry(entry);
18
+ })
19
+ );
20
+ },
21
+ forward: () => {
22
+ if (mode.value !== "reopen") return false;
23
+ options.reopen();
24
+ void nextTick(() => {
25
+ if (!options.open() && isLive(entry)) dropEntry(entry);
26
+ });
27
+ return true;
28
+ },
29
+ navigated: () => {
30
+ if (options.open()) options.navigated();
31
+ }
32
+ };
33
+ watch(
34
+ () => [options.open(), mode.value !== false],
35
+ ([open, enabled]) => {
36
+ if (open && enabled) {
37
+ if (!isLive(entry)) pushEntry(entry);
38
+ } else if (isLive(entry)) {
39
+ dropEntry(entry);
40
+ }
41
+ },
42
+ { immediate: true }
43
+ );
44
+ onBeforeUnmount(() => {
45
+ entry.alive = false;
46
+ if (isLive(entry)) dropEntry(entry);
47
+ });
48
+ provide(SCOPE, { active: () => mode.value !== false && options.open() && isLive(entry) });
49
+ }
50
+ function useStepHistory(options) {
51
+ const scope = inject(SCOPE, null);
52
+ const enabled = () => {
53
+ const own = options.mode();
54
+ if (own === false) return false;
55
+ if (scope) return scope.active();
56
+ return own === true;
57
+ };
58
+ const entries = [];
59
+ function record(from, to) {
60
+ if (from === to || !enabled()) return;
61
+ const entry = {
62
+ alive: true,
63
+ back: () => options.set(from),
64
+ forward: () => {
65
+ if (!enabled() || !options.has(to)) return false;
66
+ options.set(to);
67
+ return true;
68
+ }
69
+ };
70
+ entries.splice(0, entries.length, ...entries.filter(isLive), entry);
71
+ pushEntry(entry);
72
+ }
73
+ onBeforeUnmount(() => {
74
+ for (const entry of entries) entry.alive = false;
75
+ const first = entries.find(isLive);
76
+ if (first) dropEntry(first);
77
+ });
78
+ return { record };
79
+ }
80
+ export {
81
+ provideOverlayHistory,
82
+ useOverlayHistory,
83
+ useStepHistory
84
+ };
@@ -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";
@@ -216,6 +219,9 @@ export type { UseGamepadNavOptions } from "./composables/useGamepadNav";
216
219
  export { focusableIn, nextInDirection } from "./composables/spatialNav";
217
220
  export type { Box, Direction } from "./composables/spatialNav";
218
221
  export { provideGlass, useGlass } from "./composables/useGlass";
222
+ export { provideOverlayHistory, useOverlayHistory, useStepHistory } from "./composables/useOverlayHistory";
223
+ export type { OverlayHistoryMode, OverlayHistoryOptions, ProvideOverlayHistoryOptions, StepHistoryOptions } from "./composables/useOverlayHistory";
224
+ export type { HistoryRouter } from "./composables/overlayHistory";
219
225
  export { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig";
220
226
  export type { KitStorage, LabelScope, LpConfig } from "./composables/useLpConfig";
221
227
  export { browserStorage, useKitStorage, usePersisted } from "./composables/usePersisted";
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-CNOAUk7D.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-Al3HT5ey.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-De_LTYM7.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-DRGgh89B.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BP1ld7-S.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-n5XPAZfv.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-CpSSm5WT.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-Cpy_t1U_.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DOu7FN5P.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-Cg6Az3wz.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-wUYLN4U9.js";
@@ -30,8 +30,8 @@ import { default as default6 } from "./components/LpPaletteStats.vue.js";
30
30
  import { default as default7 } from "./components/LpPaletteSwatches.vue.js";
31
31
  import { default as default8 } from "./components/LpKeys.vue.js";
32
32
  import { comboKeys, comboSteps } from "./components/keys.js";
33
- import { _ as _18 } from "./chunks/LpConfirmDialog-DY2VjDji.js";
34
- import { _ as _19 } from "./chunks/LpConfirmHost-DvSZTJ4N.js";
33
+ import { _ as _18 } from "./chunks/LpConfirmDialog-CphJR6Q9.js";
34
+ import { _ as _19 } from "./chunks/LpConfirmHost-QSJ0zl63.js";
35
35
  import { confirm, useConfirm } from "./components/confirm.js";
36
36
  import { _ as _20 } from "./chunks/LpContextMenu-rx2tCjwk.js";
37
37
  import { _ as _21 } from "./chunks/LpDatePicker-CA0QTfBC.js";
@@ -40,7 +40,7 @@ import { _ as _23 } from "./chunks/LpRangeCalendar-CYYUYqJ7.js";
40
40
  import { clampRange, dayAllowed, defaultDatePresets, defaultRangePresets, formatRange, normalizeRange, sameRange } from "./components/dateRange.js";
41
41
  import { _ as _24 } from "./chunks/LpDisclosure-Cpa7Hc5T.js";
42
42
  import { _ as _25 } from "./chunks/LpDivider-CUX46KR0.js";
43
- import { _ as _26 } from "./chunks/LpDrawer-RRamxbLV.js";
43
+ import { _ as _26 } from "./chunks/LpDrawer-DQZixOqJ.js";
44
44
  import { _ as _27 } from "./chunks/LpDropdownMenu-Dl7Z4ZhS.js";
45
45
  import { default as default9 } from "./components/LpDropzone.vue.js";
46
46
  import { _ as _28 } from "./chunks/LpEmptyState-EQHiRYhG.js";
@@ -59,8 +59,8 @@ import { _ as _36 } from "./chunks/LpLink-Krkjhe_y.js";
59
59
  import { _ as _37 } from "./chunks/LpLogViewer-D25jM2_I.js";
60
60
  import { countLevels, matchPositions, matchesTerm, ringWindow, stepMatch } from "./components/logSearch.js";
61
61
  import { _ as _38 } from "./chunks/LpMediaGallery-Bkram-MO.js";
62
- import { _ as _39 } from "./chunks/LpModal-BnjZ2iem.js";
63
- import { _ as _40 } from "./chunks/LpModalNav-DQpV2kdn.js";
62
+ import { _ as _39 } from "./chunks/LpModal-C0tGg_D_.js";
63
+ import { _ as _40 } from "./chunks/LpModalNav-iDDtT9c1.js";
64
64
  import { _ as _41 } from "./chunks/LpNotificationBell-3244LUVc.js";
65
65
  import { _ as _42 } from "./chunks/LpNumberField-e0IBkWU1.js";
66
66
  import { _ as _43 } from "./chunks/LpTimeField-BawSVsmt.js";
@@ -86,7 +86,7 @@ import { _ as _60 } from "./chunks/LpShift-CCq7dofl.js";
86
86
  import { _ as _61 } from "./chunks/LpSwap-C96c0LWh.js";
87
87
  import { _ as _62 } from "./chunks/LpHintExample-9Py0wc-L.js";
88
88
  import { default as default11 } from "./components/LpShortcutsDialog.vue.js";
89
- import { _ as _63 } from "./chunks/LpSidebar-BoNLKgh-.js";
89
+ import { _ as _63 } from "./chunks/LpSidebar-D6zO8vLm.js";
90
90
  import { _ as _64 } from "./chunks/LpContextSwitcher-Dwh6dyID.js";
91
91
  import { _ as _65 } from "./chunks/LpSkeleton-BB6XIV-w.js";
92
92
  import { _ as _66 } from "./chunks/LpSlider-DxYoCJJO.js";
@@ -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";
@@ -132,6 +134,7 @@ import { anyModalOpen, topModalElement, useModalLayer } from "./components/modal
132
134
  import { useGamepadNav } from "./composables/useGamepadNav.js";
133
135
  import { focusableIn, nextInDirection } from "./composables/spatialNav.js";
134
136
  import { provideGlass, useGlass } from "./composables/useGlass.js";
137
+ import { provideOverlayHistory, useOverlayHistory, useStepHistory } from "./composables/useOverlayHistory.js";
135
138
  import { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig.js";
136
139
  import { browserStorage, useKitStorage, usePersisted } from "./composables/usePersisted.js";
137
140
  import { useCloseGuard } from "./composables/useCloseGuard.js";
@@ -469,6 +472,7 @@ export {
469
472
  fileNameOf,
470
473
  fileStem,
471
474
  filterCountries,
475
+ filterRows,
472
476
  findHotkeyConflicts,
473
477
  fitChips,
474
478
  flagEmoji,
@@ -568,6 +572,7 @@ export {
568
572
  provideGlass,
569
573
  provideHintSystem,
570
574
  provideLpConfig,
575
+ provideOverlayHistory,
571
576
  pseudoLocalize,
572
577
  pushRecent,
573
578
  quotaSegments,
@@ -609,6 +614,8 @@ export {
609
614
  setActiveTab,
610
615
  s as setGlassBackdrop,
611
616
  settledCells,
617
+ showOnly,
618
+ shownValues,
612
619
  sortNodes,
613
620
  splitTags,
614
621
  splitTime,
@@ -665,6 +672,7 @@ export {
665
672
  useModalLayer,
666
673
  useNow,
667
674
  useOptimisticToggle,
675
+ useOverlayHistory,
668
676
  usePaintSelect,
669
677
  usePaletteCommands,
670
678
  u3 as usePaletteTheme,
@@ -680,6 +688,8 @@ export {
680
688
  useSettled,
681
689
  useShift,
682
690
  useSpotlight,
691
+ useStepHistory,
692
+ useTableFilterChoice,
683
693
  u as useTheme,
684
694
  u2 as useThemeStudio,
685
695
  useTilt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.74.2",
3
+ "version": "0.76.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -101,6 +101,7 @@
101
101
  "vite-plugin-lib-inject-css": "^2.2.2",
102
102
  "vue": "3.5.31",
103
103
  "vue-component-meta": "3.3.5",
104
+ "vue-router": "4.6.4",
104
105
  "vue-tsc": "^3.0.0"
105
106
  },
106
107
  "dependencies": {
@@ -5,6 +5,7 @@ import { useLabels } from "../composables/useLpConfig"
5
5
  import LpButton from "./LpButton.vue"
6
6
  import LpIcon from "./LpIcon.vue"
7
7
  import LpModal from "./LpModal.vue"
8
+ import type { OverlayHistoryMode } from "../composables/useOverlayHistory"
8
9
 
9
10
  export type ConfirmLabels = {
10
11
  confirm: string
@@ -54,9 +55,11 @@ const props = withDefaults(
54
55
  action?: () => unknown
55
56
  /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
56
57
  glass?: boolean
58
+ /** Browser history for the dialog, passed to LpModal. Follows `provideOverlayHistory` when unset. */
59
+ history?: OverlayHistoryMode
57
60
  labels?: Partial<ConfirmLabels>
58
61
  }>(),
59
- { glass: undefined },
62
+ { glass: undefined, history: undefined },
60
63
  )
61
64
 
62
65
  const emit = defineEmits<{
@@ -128,6 +131,7 @@ function dismiss(open: boolean) {
128
131
  <template>
129
132
  <LpModal
130
133
  :glass="glass"
134
+ :history="history"
131
135
  :open="open"
132
136
  :title="title"
133
137
  :description="description"
@@ -37,6 +37,7 @@ import { CLOSE_ICON } from "./dropdown"
37
37
  import { prefersReducedMotion } from "../composables/easing"
38
38
  import { useCloseGuard, type CloseGuard } from "../composables/useCloseGuard"
39
39
  import { useGlass } from "../composables/useGlass"
40
+ import { useOverlayHistory, type OverlayHistoryMode } from "../composables/useOverlayHistory"
40
41
  import { useLabels } from "../composables/useLpConfig"
41
42
  import { usePersisted } from "../composables/usePersisted"
42
43
  import LpIcon from "./LpIcon.vue"
@@ -132,9 +133,17 @@ const props = withDefaults(
132
133
  glass?: boolean
133
134
  /** Accessible name for a drawer without a visible `title`. */
134
135
  ariaLabel?: string
136
+ /**
137
+ * Browser Back closes it, and opening it adds a history entry. Unset
138
+ * follows `provideOverlayHistory` (off without one); `"reopen"` also
139
+ * opens it again on Forward; `false` keeps it out of history, for an
140
+ * overlay the app already drives from its route.
141
+ */
142
+ history?: OverlayHistoryMode
135
143
  labels?: Partial<{ close: string }>
136
144
  }>(),
137
145
  {
146
+ history: undefined,
138
147
  side: "right",
139
148
  size: "sm",
140
149
  dismissible: true,
@@ -214,6 +223,18 @@ async function requestClose() {
214
223
  // ways out do. Handed to the body and footer slots, and exposed.
215
224
  defineExpose({ close: requestClose })
216
225
 
226
+ useOverlayHistory({
227
+ mode: () => props.history,
228
+ open: () => !!props.open,
229
+ close: async () => {
230
+ if (!(await allowClose())) return false
231
+ emit("update:open", false)
232
+ return true
233
+ },
234
+ reopen: () => emit("update:open", true),
235
+ navigated: () => emit("update:open", false),
236
+ })
237
+
217
238
  // The header's close button is vaul's own and would slide the panel away at
218
239
  // once; with a guard it asks first and closes through the same path as above.
219
240
  function guardClose(event: MouseEvent) {
@@ -11,6 +11,7 @@ import {
11
11
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
12
  import { useCloseGuard, type CloseGuard } from "../composables/useCloseGuard"
13
13
  import { useGlass } from "../composables/useGlass"
14
+ import { useOverlayHistory, type OverlayHistoryMode } from "../composables/useOverlayHistory"
14
15
  import { useLabels } from "../composables/useLpConfig"
15
16
  import { useShift } from "../composables/useShift"
16
17
  import { useModalLayer } from "./modalDepth"
@@ -93,9 +94,16 @@ const props = withDefaults(
93
94
  * form's state, `:guard="form.dirty"`. See `useCloseGuard`.
94
95
  */
95
96
  guard?: CloseGuard
97
+ /**
98
+ * Browser Back closes it, and opening it adds a history entry. Unset
99
+ * follows `provideOverlayHistory` (off without one); `"reopen"` also
100
+ * opens it again on Forward; `false` keeps it out of history, for an
101
+ * overlay the app already drives from its route.
102
+ */
103
+ history?: OverlayHistoryMode
96
104
  labels?: Partial<{ close: string }>
97
105
  }>(),
98
- { size: "md", autoFocus: "auto", glass: undefined, dismissible: true, guard: undefined },
106
+ { size: "md", autoFocus: "auto", glass: undefined, dismissible: true, guard: undefined, history: undefined },
99
107
  )
100
108
 
101
109
  const text = useLabels("modal", { close: "Close" }, () => props.labels)
@@ -120,6 +128,18 @@ async function onOpenChange(next: boolean) {
120
128
  function close() {
121
129
  void onOpenChange(false)
122
130
  }
131
+
132
+ useOverlayHistory({
133
+ mode: () => props.history,
134
+ open: () => !!props.open,
135
+ close: async () => {
136
+ if (!(await allowClose())) return false
137
+ emit("update:open", false)
138
+ return true
139
+ },
140
+ reopen: () => emit("update:open", true),
141
+ navigated: () => emit("update:open", false),
142
+ })
123
143
  defineExpose({ close })
124
144
 
125
145
  function onEscape(event: KeyboardEvent) {
@@ -18,6 +18,7 @@
18
18
  import { computed } from "vue"
19
19
  import LpKeys from "./LpKeys.vue"
20
20
  import LpModal from "./LpModal.vue"
21
+ import type { OverlayHistoryMode } from "../composables/useOverlayHistory"
21
22
  import {
22
23
  activeShortcuts,
23
24
  mergeShortcutGroups,
@@ -36,6 +37,8 @@ const props = withDefaults(
36
37
  description?: string
37
38
  /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
38
39
  glass?: boolean
40
+ /** Browser history for the dialog, passed to LpModal. Follows `provideOverlayHistory` when unset. */
41
+ history?: OverlayHistoryMode
39
42
  /**
40
43
  * A key that toggles the reference, e.g. "F1". Bound only while the dialog
41
44
  * is mounted, also inside inputs and over other overlays, and the browser's
@@ -43,7 +46,7 @@ const props = withDefaults(
43
46
  */
44
47
  hotkey?: string
45
48
  }>(),
46
- { open: false, groups: () => [], live: false, title: undefined, glass: undefined },
49
+ { open: false, groups: () => [], live: false, title: undefined, glass: undefined, history: undefined },
47
50
  )
48
51
 
49
52
  const text = useLabels("shortcuts", {
@@ -75,6 +78,7 @@ useHotkeys(() =>
75
78
  <template>
76
79
  <LpModal
77
80
  :glass="props.glass"
81
+ :history="props.history"
78
82
  :open="props.open"
79
83
  size="lg"
80
84
  :title="props.title ?? text.title"
@@ -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>