@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.
- package/README.md +44 -0
- package/dist/assets/{LpShortcutsDialog-BUp_BX4s.css → LpShortcutsDialog-C6uzPeMf.css} +7 -7
- package/dist/assets/{LpTable-DHvorDiU.css → LpTable-Bv-EcQs7.css} +19 -19
- package/dist/assets/LpTabs-CJ0rYQbx.css +18 -0
- package/dist/chunks/{LpAddressInput-Al3HT5ey.js → LpAddressInput-De_LTYM7.js} +1 -1
- package/dist/chunks/{LpAppShell-CpSSm5WT.js → LpAppShell-Cpy_t1U_.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-DY2VjDji.js → LpConfirmDialog-CphJR6Q9.js} +4 -2
- package/dist/chunks/{LpConfirmHost-DvSZTJ4N.js → LpConfirmHost-QSJ0zl63.js} +1 -1
- package/dist/chunks/{LpDrawer-RRamxbLV.js → LpDrawer-DQZixOqJ.js} +13 -0
- package/dist/chunks/{LpModal-BnjZ2iem.js → LpModal-C0tGg_D_.js} +13 -0
- package/dist/chunks/{LpModalNav-DQpV2kdn.js → LpModalNav-iDDtT9c1.js} +2 -2
- package/dist/chunks/{LpSidebar-BoNLKgh-.js → LpSidebar-D6zO8vLm.js} +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +4 -0
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +16 -0
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +9 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +4 -0
- package/dist/components/LpShortcutsDialog.vue.js +6 -4
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +37 -0
- package/dist/components/LpTable.vue.js +34 -15
- package/dist/components/LpTabs.vue.d.ts +9 -0
- package/dist/components/LpTabs.vue.js +18 -4
- package/dist/components/tableFilter.d.ts +28 -0
- package/dist/components/tableFilter.js +43 -0
- package/dist/composables/overlayHistory.d.ts +33 -0
- package/dist/composables/overlayHistory.js +237 -0
- package/dist/composables/useOverlayHistory.d.ts +45 -0
- package/dist/composables/useOverlayHistory.js +84 -0
- package/dist/composables/useTableFilterChoice.d.ts +16 -0
- package/dist/composables/useTableFilterChoice.js +34 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -8
- package/package.json +2 -1
- package/src/components/LpConfirmDialog.vue +5 -1
- package/src/components/LpDrawer.vue +21 -0
- package/src/components/LpModal.vue +21 -1
- package/src/components/LpShortcutsDialog.vue +5 -1
- package/src/components/LpTable.vue +86 -23
- package/src/components/LpTabs.vue +26 -2
- package/src/components/tableFilter.ts +66 -0
- package/src/composables/overlayHistory.ts +381 -0
- package/src/composables/useOverlayHistory.ts +173 -0
- package/src/composables/useTableFilterChoice.ts +51 -0
- package/src/index.ts +6 -0
- package/dist/assets/LpTabs-CDchzoRL.css +0 -18
|
@@ -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
|
};
|
|
@@ -52,6 +52,14 @@ type __VLS_Props = {
|
|
|
52
52
|
* icon keep their label.
|
|
53
53
|
*/
|
|
54
54
|
compact?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Record each switch as a browser history entry, so Back returns to the
|
|
57
|
+
* previous tab. Unset follows the overlay the tabs sit in (a modal or a
|
|
58
|
+
* drawer with history on, see `provideOverlayHistory`) and is off
|
|
59
|
+
* elsewhere: a page's own tabs belong in its URL. `false` opts out inside
|
|
60
|
+
* an overlay, `true` opts in on a page that keeps no URL of its own.
|
|
61
|
+
*/
|
|
62
|
+
history?: boolean;
|
|
55
63
|
};
|
|
56
64
|
declare var __VLS_67: {
|
|
57
65
|
value: string;
|
|
@@ -66,6 +74,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
|
|
|
66
74
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
67
75
|
"onUpdate:modelValue"?: ((value: string) => any) | undefined;
|
|
68
76
|
}>, {
|
|
77
|
+
history: boolean;
|
|
69
78
|
block: boolean;
|
|
70
79
|
variant: "contained" | "plain" | "underline" | "bottom";
|
|
71
80
|
accent: boolean;
|
|
@@ -3,13 +3,14 @@ import { usePaletteScope, usePaletteCommands } from "../composables/usePaletteCo
|
|
|
3
3
|
import { Motion } from "motion-v";
|
|
4
4
|
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from "reka-ui";
|
|
5
5
|
import { usePillTransition } from "../composables/usePillTransition.js";
|
|
6
|
+
import { useStepHistory } from "../composables/useOverlayHistory.js";
|
|
6
7
|
import { prefersReducedMotion } from "../composables/easing.js";
|
|
7
8
|
import { _ as _sfc_main$1 } from "../chunks/LpContextMenu-rx2tCjwk.js";
|
|
8
9
|
import { _ as _sfc_main$3 } from "../chunks/LpSkeleton-BB6XIV-w.js";
|
|
9
10
|
import { _ as _sfc_main$2 } from "../chunks/LpTabFace-DPCwKkH2.js";
|
|
10
11
|
import { pickTab, BOTTOM_BAR, BOTTOM_ITEM, BOTTOM_MARKER } from "./tabs.js";
|
|
11
12
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
12
|
-
import '../assets/LpTabs-
|
|
13
|
+
import '../assets/LpTabs-CJ0rYQbx.css';const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
13
14
|
__name: "LpTabs",
|
|
14
15
|
props: {
|
|
15
16
|
modelValue: {},
|
|
@@ -20,7 +21,8 @@ import '../assets/LpTabs-CDchzoRL.css';const _sfc_main = /* @__PURE__ */ defineC
|
|
|
20
21
|
block: { type: Boolean, default: false },
|
|
21
22
|
label: {},
|
|
22
23
|
hoverFollow: { type: Boolean, default: true },
|
|
23
|
-
compact: { type: Boolean, default: false }
|
|
24
|
+
compact: { type: Boolean, default: false },
|
|
25
|
+
history: { type: Boolean, default: void 0 }
|
|
24
26
|
},
|
|
25
27
|
emits: ["update:modelValue"],
|
|
26
28
|
setup(__props, { emit: __emit }) {
|
|
@@ -54,10 +56,22 @@ import '../assets/LpTabs-CDchzoRL.css';const _sfc_main = /* @__PURE__ */ defineC
|
|
|
54
56
|
row.scrollTo({ left, behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
55
57
|
});
|
|
56
58
|
});
|
|
57
|
-
function
|
|
59
|
+
function show(value) {
|
|
58
60
|
if (props.modelValue === void 0) current.value = value;
|
|
59
61
|
emit("update:modelValue", value);
|
|
60
62
|
}
|
|
63
|
+
const { record } = useStepHistory({
|
|
64
|
+
mode: () => props.history,
|
|
65
|
+
set: show,
|
|
66
|
+
has: (value) => props.items.some((item) => item.value === value && !item.disabled)
|
|
67
|
+
});
|
|
68
|
+
function select(value) {
|
|
69
|
+
const from = current.value;
|
|
70
|
+
show(value);
|
|
71
|
+
void nextTick(() => {
|
|
72
|
+
if (from !== void 0 && current.value === value) record(from, value);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
61
75
|
const pillId = `lp-tab-indicator-${useId()}`;
|
|
62
76
|
const hovered = ref(null);
|
|
63
77
|
const pillTransition = usePillTransition();
|
|
@@ -195,7 +209,7 @@ import '../assets/LpTabs-CDchzoRL.css';const _sfc_main = /* @__PURE__ */ defineC
|
|
|
195
209
|
};
|
|
196
210
|
}
|
|
197
211
|
});
|
|
198
|
-
const LpTabs = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
212
|
+
const LpTabs = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-1d673b52"]]);
|
|
199
213
|
export {
|
|
200
214
|
LpTabs as default
|
|
201
215
|
};
|
|
@@ -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,33 @@
|
|
|
1
|
+
export interface HistoryEntry {
|
|
2
|
+
/** Back left this entry: undo what it recorded (close the overlay, restore the tab). */
|
|
3
|
+
back: () => void;
|
|
4
|
+
/** Forward came back to it: redo it, and say whether that happened. */
|
|
5
|
+
forward?: () => boolean;
|
|
6
|
+
/** The page moved on to another path while this entry was live. */
|
|
7
|
+
navigated?: () => void;
|
|
8
|
+
/** The component that pushed it is still mounted. */
|
|
9
|
+
alive: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What the kit needs of a router: vue-router's `listening` switch, or the same
|
|
13
|
+
* flag on any router that skips its popstate handling while it is false.
|
|
14
|
+
*/
|
|
15
|
+
export interface HistoryRouter {
|
|
16
|
+
listening: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** The app's router, told not to listen while a step only concerns the kit. */
|
|
19
|
+
export declare function setHistoryRouter(router: HistoryRouter | null): void;
|
|
20
|
+
/** Record an entry on top of the history, under the current address. */
|
|
21
|
+
export declare function pushEntry(entry: HistoryEntry): void;
|
|
22
|
+
/**
|
|
23
|
+
* The thing behind an entry ended without Back (a close button, Escape, the
|
|
24
|
+
* app setting `open` to false, an unmount): take the entry and everything
|
|
25
|
+
* pushed above it out of history, so Back is never spent on a dead step.
|
|
26
|
+
* When the page is not sitting on the kit's entries (another push came on top),
|
|
27
|
+
* the entry stays in place and is skipped when Back or Forward lands on it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function dropEntry(entry: HistoryEntry): void;
|
|
30
|
+
/** Whether the entry is live: pushed and not yet popped or dropped. */
|
|
31
|
+
export declare function isLive(entry: HistoryEntry): boolean;
|
|
32
|
+
/** Live entries, bottom first. For tests and devtools. */
|
|
33
|
+
export declare function liveEntries(): number;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
const KEY = "__lpOverlay";
|
|
2
|
+
const SETTLE_MS = 300;
|
|
3
|
+
const STATE_KEY = /* @__PURE__ */ Symbol.for("@leavepulse/ui:overlay-history");
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
const fresh = {
|
|
6
|
+
router: null,
|
|
7
|
+
replaying: false,
|
|
8
|
+
popping: null,
|
|
9
|
+
installed: false,
|
|
10
|
+
live: [],
|
|
11
|
+
closed: /* @__PURE__ */ new Map(),
|
|
12
|
+
dead: /* @__PURE__ */ new Map(),
|
|
13
|
+
silent: 0,
|
|
14
|
+
waiting: [],
|
|
15
|
+
href: "",
|
|
16
|
+
serial: 0,
|
|
17
|
+
internal: false,
|
|
18
|
+
settle: 0
|
|
19
|
+
};
|
|
20
|
+
const s = g[STATE_KEY] ??= fresh;
|
|
21
|
+
function markOf(state) {
|
|
22
|
+
if (!state || typeof state !== "object") return null;
|
|
23
|
+
const mark = state[KEY];
|
|
24
|
+
if (!mark || typeof mark !== "object") return null;
|
|
25
|
+
const { token, depth } = mark;
|
|
26
|
+
return typeof token === "string" && typeof depth === "number" ? { token, depth } : null;
|
|
27
|
+
}
|
|
28
|
+
function pathOf(href) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(href, location.href).pathname;
|
|
31
|
+
} catch {
|
|
32
|
+
return href;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function own(run) {
|
|
36
|
+
s.internal = true;
|
|
37
|
+
try {
|
|
38
|
+
return run();
|
|
39
|
+
} finally {
|
|
40
|
+
s.internal = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function silentGo(delta) {
|
|
44
|
+
if (delta === 0) return;
|
|
45
|
+
s.silent++;
|
|
46
|
+
mute();
|
|
47
|
+
own(() => history.go(delta));
|
|
48
|
+
}
|
|
49
|
+
function whenSettled(run) {
|
|
50
|
+
if (s.silent > 0) s.waiting.push(run);
|
|
51
|
+
else run();
|
|
52
|
+
}
|
|
53
|
+
function flushWaiting() {
|
|
54
|
+
if (s.silent > 0) return;
|
|
55
|
+
const queued = s.waiting.splice(0);
|
|
56
|
+
for (const run of queued) run();
|
|
57
|
+
}
|
|
58
|
+
function current() {
|
|
59
|
+
return markOf(history.state);
|
|
60
|
+
}
|
|
61
|
+
function onTop() {
|
|
62
|
+
const top = s.live[s.live.length - 1];
|
|
63
|
+
return !!top && current()?.token === top.token;
|
|
64
|
+
}
|
|
65
|
+
function leavePage() {
|
|
66
|
+
const gone = s.live.splice(0);
|
|
67
|
+
for (const item of gone.reverse()) {
|
|
68
|
+
bury(item);
|
|
69
|
+
item.entry.navigated?.();
|
|
70
|
+
}
|
|
71
|
+
s.closed.clear();
|
|
72
|
+
}
|
|
73
|
+
function handlePop(event) {
|
|
74
|
+
const before = s.href;
|
|
75
|
+
s.href = location.href;
|
|
76
|
+
if (s.silent > 0) {
|
|
77
|
+
s.silent--;
|
|
78
|
+
if (before === location.href) event.stopImmediatePropagation();
|
|
79
|
+
flushWaiting();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
window.clearTimeout(s.settle);
|
|
83
|
+
const sameAddress = before === location.href;
|
|
84
|
+
const mark = markOf(event.state);
|
|
85
|
+
const at = mark ? s.live.findIndex((item) => item.token === mark.token) : -1;
|
|
86
|
+
if (at >= 0) {
|
|
87
|
+
closeAbove(at);
|
|
88
|
+
if (sameAddress) event.stopImmediatePropagation();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const reopen = mark ? s.closed.get(mark.token) : void 0;
|
|
92
|
+
if (reopen && sameAddress) {
|
|
93
|
+
event.stopImmediatePropagation();
|
|
94
|
+
if (reopen.entry.alive && reopen.entry.forward?.()) {
|
|
95
|
+
s.closed.delete(reopen.token);
|
|
96
|
+
s.live.push(reopen);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
silentGo(-1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (mark && (s.dead.has(mark.token) || reopen)) {
|
|
103
|
+
if (sameAddress) event.stopImmediatePropagation();
|
|
104
|
+
if (!sameAddress) {
|
|
105
|
+
silentGo(-mark.depth);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const depth = mark.depth;
|
|
109
|
+
own(() => history.forward());
|
|
110
|
+
s.settle = window.setTimeout(() => {
|
|
111
|
+
if (markOf(history.state)?.token === mark.token) silentGo(-depth);
|
|
112
|
+
else mute();
|
|
113
|
+
}, SETTLE_MS);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (s.live.length === 0) return;
|
|
117
|
+
if (sameAddress) {
|
|
118
|
+
closeAbove(-1);
|
|
119
|
+
event.stopImmediatePropagation();
|
|
120
|
+
} else if (pathOf(before) !== pathOf(location.href)) {
|
|
121
|
+
leavePage();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const DEAD_LIMIT = 200;
|
|
125
|
+
function bury(item) {
|
|
126
|
+
s.dead.set(item.token, item);
|
|
127
|
+
if (s.dead.size > DEAD_LIMIT) s.dead.delete(s.dead.keys().next().value);
|
|
128
|
+
}
|
|
129
|
+
function closeAbove(index) {
|
|
130
|
+
const popped = s.live.splice(index + 1);
|
|
131
|
+
for (const item of popped.reverse()) {
|
|
132
|
+
s.closed.set(item.token, item);
|
|
133
|
+
if (item.entry.alive) item.entry.back();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function onPop(event) {
|
|
137
|
+
if (s.replaying) return;
|
|
138
|
+
s.popping = { muted: !!s.router && !s.router.listening, before: s.href };
|
|
139
|
+
handlePop(event);
|
|
140
|
+
if (event.cancelBubble) {
|
|
141
|
+
s.popping = null;
|
|
142
|
+
mute();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function afterPop(event) {
|
|
146
|
+
if (s.replaying || !s.popping) return;
|
|
147
|
+
const { muted, before } = s.popping;
|
|
148
|
+
s.popping = null;
|
|
149
|
+
if (muted && before !== location.href) replay(event.state);
|
|
150
|
+
mute();
|
|
151
|
+
}
|
|
152
|
+
function replay(state) {
|
|
153
|
+
if (!s.router) return;
|
|
154
|
+
s.router.listening = true;
|
|
155
|
+
s.replaying = true;
|
|
156
|
+
try {
|
|
157
|
+
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
|
158
|
+
} finally {
|
|
159
|
+
s.replaying = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function mute() {
|
|
163
|
+
if (s.popping) return;
|
|
164
|
+
if (s.router) s.router.listening = !(current() || s.silent > 0);
|
|
165
|
+
}
|
|
166
|
+
function setHistoryRouter(router) {
|
|
167
|
+
if (s.router && s.router !== router) s.router.listening = true;
|
|
168
|
+
s.router = router;
|
|
169
|
+
if (typeof window === "undefined") return;
|
|
170
|
+
window.removeEventListener("popstate", afterPop);
|
|
171
|
+
window.addEventListener("popstate", afterPop);
|
|
172
|
+
mute();
|
|
173
|
+
}
|
|
174
|
+
function install() {
|
|
175
|
+
if (s.installed || typeof window === "undefined") return;
|
|
176
|
+
s.installed = true;
|
|
177
|
+
s.href = location.href;
|
|
178
|
+
window.addEventListener("popstate", onPop, { capture: true });
|
|
179
|
+
window.removeEventListener("popstate", afterPop);
|
|
180
|
+
window.addEventListener("popstate", afterPop);
|
|
181
|
+
const push = history.pushState.bind(history);
|
|
182
|
+
const replace = history.replaceState.bind(history);
|
|
183
|
+
history.pushState = function(data, unused, url) {
|
|
184
|
+
const before = location.href;
|
|
185
|
+
push(data, unused, url);
|
|
186
|
+
s.href = location.href;
|
|
187
|
+
if (s.internal) return;
|
|
188
|
+
s.closed.clear();
|
|
189
|
+
if (pathOf(before) !== pathOf(location.href)) leavePage();
|
|
190
|
+
mute();
|
|
191
|
+
};
|
|
192
|
+
history.replaceState = function(data, unused, url) {
|
|
193
|
+
const mark = s.internal ? null : current();
|
|
194
|
+
const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...data, [KEY]: mark } : data;
|
|
195
|
+
replace(keep, unused, url);
|
|
196
|
+
s.href = location.href;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function pushEntry(entry) {
|
|
200
|
+
if (typeof window === "undefined") return;
|
|
201
|
+
install();
|
|
202
|
+
whenSettled(() => {
|
|
203
|
+
if (!entry.alive) return;
|
|
204
|
+
const token = `${Date.now().toString(36)}-${(s.serial++).toString(36)}`;
|
|
205
|
+
const depth = (current()?.depth ?? 0) + 1;
|
|
206
|
+
const base = history.state && typeof history.state === "object" ? history.state : {};
|
|
207
|
+
own(() => history.pushState({ ...base, [KEY]: { token, depth } }, "", location.href));
|
|
208
|
+
s.href = location.href;
|
|
209
|
+
s.closed.clear();
|
|
210
|
+
s.live.push({ entry, token, depth });
|
|
211
|
+
mute();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function dropEntry(entry) {
|
|
215
|
+
const at = s.live.findIndex((item) => item.entry === entry);
|
|
216
|
+
if (at === -1) return;
|
|
217
|
+
const positioned = onTop();
|
|
218
|
+
const removed = s.live.splice(at);
|
|
219
|
+
if (positioned) {
|
|
220
|
+
silentGo(-removed.length);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const item of removed) bury(item);
|
|
224
|
+
}
|
|
225
|
+
function isLive(entry) {
|
|
226
|
+
return s.live.some((item) => item.entry === entry);
|
|
227
|
+
}
|
|
228
|
+
function liveEntries() {
|
|
229
|
+
return s.live.length;
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
dropEntry,
|
|
233
|
+
isLive,
|
|
234
|
+
liveEntries,
|
|
235
|
+
pushEntry,
|
|
236
|
+
setHistoryRouter
|
|
237
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter } from "vue";
|
|
2
|
+
import { type HistoryRouter } from "./overlayHistory";
|
|
3
|
+
export type OverlayHistoryMode = boolean | "reopen";
|
|
4
|
+
export interface ProvideOverlayHistoryOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The app's router, so a Back or Forward that only closes an overlay does
|
|
7
|
+
* not reach it as a navigation (vue-router: the router instance). Its
|
|
8
|
+
* popstate listener runs before the kit's, so without this it sees each of
|
|
9
|
+
* those steps as a visit to the page it is already on: guards and
|
|
10
|
+
* `afterEach` run again and the route object is replaced, though nothing
|
|
11
|
+
* remounts. One router per page; the latest one given is used.
|
|
12
|
+
*/
|
|
13
|
+
router?: HistoryRouter;
|
|
14
|
+
}
|
|
15
|
+
/** Turn browser history on for every overlay below the calling component. */
|
|
16
|
+
export declare function provideOverlayHistory(mode?: MaybeRefOrGetter<OverlayHistoryMode>, options?: ProvideOverlayHistoryOptions): void;
|
|
17
|
+
export interface OverlayHistoryOptions {
|
|
18
|
+
/** The overlay's own `history` prop; `undefined` follows `provideOverlayHistory`. */
|
|
19
|
+
mode: () => OverlayHistoryMode | undefined;
|
|
20
|
+
open: () => boolean;
|
|
21
|
+
/** Back left the entry: close the way the overlay's own ways out do. */
|
|
22
|
+
close: () => Promise<unknown>;
|
|
23
|
+
/** Forward under `"reopen"`: ask the app to open it again (`update:open` true). */
|
|
24
|
+
reopen: () => void;
|
|
25
|
+
/** The page went to another path: close without asking, the place it belonged to is gone. */
|
|
26
|
+
navigated: () => void;
|
|
27
|
+
}
|
|
28
|
+
/** Wire an overlay's `open` to a history entry. */
|
|
29
|
+
export declare function useOverlayHistory(options: OverlayHistoryOptions): void;
|
|
30
|
+
export interface StepHistoryOptions {
|
|
31
|
+
/** The component's own `history` prop; `undefined` follows the overlay it sits in. */
|
|
32
|
+
mode: () => boolean | undefined;
|
|
33
|
+
/** Show this value without recording it. */
|
|
34
|
+
set: (value: string) => void;
|
|
35
|
+
/** Whether the value can still be shown (the tab exists and is enabled). */
|
|
36
|
+
has: (value: string) => boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Steps inside an overlay (a tab switch) as history entries. Outside an overlay
|
|
40
|
+
* it is off unless asked for: a page's own tabs belong in its URL, where the
|
|
41
|
+
* app's router already keeps them.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useStepHistory(options: StepHistoryOptions): {
|
|
44
|
+
record: (from: string, to: string) => void;
|
|
45
|
+
};
|