@leavepulse/ui 0.72.5 → 0.73.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 (67) hide show
  1. package/dist/assets/{LpNumberFlow-Chp9pEDe.css → LpNumberFlow-P1balINP.css} +1 -1
  2. package/dist/chunks/{LpAppShell-DMSJg18Q.js → LpAppShell-rr5kqiK3.js} +8 -2
  3. package/dist/chunks/{LpCodeDisplay-AQx-3nNb.js → LpCodeDisplay-CE9TxCA4.js} +1 -1
  4. package/dist/chunks/{LpCommandScope-BlKcgBnK.js → LpCommandScope-CtUhJe8T.js} +1 -1
  5. package/dist/chunks/{LpCountdown-BxE7stYk.js → LpCountdown-DgeY3cLG.js} +1 -1
  6. package/dist/chunks/{LpGauge-BO1PYYqD.js → LpGauge-KZaQ_FDR.js} +1 -1
  7. package/dist/chunks/{LpModalNav-CE3iLnxB.js → LpModalNav-DQpV2kdn.js} +1 -1
  8. package/dist/chunks/{LpNumberFlow-CcxEGUy0.js → LpNumberFlow-Vqj0tP7B.js} +1 -1
  9. package/dist/chunks/{LpPaletteTheme-CdEEMMEF.js → LpPaletteTheme-DiLYKQiZ.js} +1 -1
  10. package/dist/chunks/{LpPie-Mr3EtFEd.js → LpPie-DrZepi0p.js} +1 -1
  11. package/dist/chunks/{LpProgress-rOlyafqp.js → LpProgress-Yr6ONG5t.js} +1 -1
  12. package/dist/chunks/{LpQuota-B12yijdB.js → LpQuota-Dk2MyI2Z.js} +1 -1
  13. package/dist/chunks/{LpSidebar-BciLMBgY.js → LpSidebar-BoNLKgh-.js} +1 -1
  14. package/dist/chunks/{LpSidebarNav-HYcG-ib8.js → LpSidebarNav-Bky8chrv.js} +1 -1
  15. package/dist/chunks/{LpSidebarRow-Di2VYz-Z.js → LpSidebarRow-BRYhNDSo.js} +1 -1
  16. package/dist/chunks/{LpStat-D6V6Fdfc.js → LpStat-BVf2UvEc.js} +1 -1
  17. package/dist/chunks/{LpTableOfContents-CraicEs4.js → LpTableOfContents-BN5zvpnx.js} +2 -2
  18. package/dist/chunks/{LpThemeStudio-BJDKf3_Z.js → LpThemeStudio-BpORDvH6.js} +1 -1
  19. package/dist/chunks/{LpThemeSwitcher-BDrIJswA.js → LpThemeSwitcher-BnEn_mvf.js} +2 -2
  20. package/dist/chunks/{LpUptimeBar-BtkZRwY0.js → LpUptimeBar-DqNvvMBK.js} +1 -1
  21. package/dist/chunks/{palette-Cvxl_1tM.js → palette-Co8tSASo.js} +1 -1
  22. package/dist/components/LpAppShell.vue.d.ts +20 -0
  23. package/dist/components/LpAppShell.vue.js +1 -1
  24. package/dist/components/LpCodeDisplay.vue.js +1 -1
  25. package/dist/components/LpCommandScope.vue.js +1 -1
  26. package/dist/components/LpCountdown.vue.js +1 -1
  27. package/dist/components/LpGauge.vue.js +1 -1
  28. package/dist/components/LpModalNav.vue.js +1 -1
  29. package/dist/components/LpNumberFlow.vue.js +1 -1
  30. package/dist/components/LpPaletteStats.vue.js +1 -1
  31. package/dist/components/LpPaletteTheme.vue.js +1 -1
  32. package/dist/components/LpPie.vue.js +1 -1
  33. package/dist/components/LpProgress.vue.js +1 -1
  34. package/dist/components/LpQuota.vue.js +1 -1
  35. package/dist/components/LpSidebar.vue.js +1 -1
  36. package/dist/components/LpSidebarNav.vue.js +1 -1
  37. package/dist/components/LpSidebarRow.vue.js +1 -1
  38. package/dist/components/LpStat.vue.js +1 -1
  39. package/dist/components/LpTableOfContents.vue.js +1 -1
  40. package/dist/components/LpThemeStudio.vue.js +1 -1
  41. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  42. package/dist/components/LpUptimeBar.vue.js +1 -1
  43. package/dist/components/commandSearch.d.ts +2 -1
  44. package/dist/components/commandSearch.js +15 -6
  45. package/dist/components/format.d.ts +6 -6
  46. package/dist/components/quota.d.ts +1 -1
  47. package/dist/components/timeSpan.d.ts +1 -1
  48. package/dist/composables/scroll.d.ts +2 -0
  49. package/dist/composables/scroll.js +4 -0
  50. package/dist/composables/scrollMemory.d.ts +1 -0
  51. package/dist/composables/scrollMemory.js +111 -0
  52. package/dist/index.js +18 -18
  53. package/dist/theme/palette.d.ts +2 -1
  54. package/package.json +1 -1
  55. package/src/components/LpAppShell.vue +18 -2
  56. package/src/components/LpNumberFlow.vue +3 -3
  57. package/src/components/LpStat.vue +1 -1
  58. package/src/components/LpTableOfContents.vue +4 -2
  59. package/src/components/commandSearch.ts +17 -6
  60. package/src/components/format.ts +9 -9
  61. package/src/components/quota.ts +1 -1
  62. package/src/components/timeSpan.ts +1 -1
  63. package/src/composables/scroll.ts +5 -0
  64. package/src/composables/scrollMemory.ts +145 -0
  65. package/src/composables/useLocale.ts +1 -1
  66. package/src/nuxt.ts +1 -14
  67. package/src/theme/palette.ts +3 -3
@@ -0,0 +1,111 @@
1
+ import { watch, nextTick, onMounted, onBeforeUnmount } from "vue";
2
+ import { scrollMarginTop } from "./scroll.js";
3
+ const SETTLE_MS = 1500;
4
+ const DIALOG_WAIT_MS = 2e3;
5
+ const SETTLE_STOP = ["wheel", "touchstart", "keydown", "pointerdown"];
6
+ const positions = /* @__PURE__ */ new Map();
7
+ function addressKey() {
8
+ return `${location.pathname}${location.search}`;
9
+ }
10
+ function useScrollMemory(viewport, path, hash) {
11
+ if (typeof window === "undefined") return;
12
+ let current = addressKey();
13
+ let shown = path();
14
+ let travelled = false;
15
+ let stopHold;
16
+ let pending = 0;
17
+ const onPop = () => {
18
+ travelled = true;
19
+ };
20
+ window.addEventListener("popstate", onPop);
21
+ function anchor(value) {
22
+ if (!value || value === "#") return null;
23
+ const id = decodeURIComponent(value.replace(/^#/, ""));
24
+ return document.getElementById(id);
25
+ }
26
+ function reach(value, behavior) {
27
+ const view = viewport();
28
+ const target = anchor(value);
29
+ if (!view || !target || !view.contains(target)) return false;
30
+ const top = target.getBoundingClientRect().top - view.getBoundingClientRect().top + view.scrollTop - scrollMarginTop(target);
31
+ view.scrollTo({ top: Math.max(0, top), behavior });
32
+ return true;
33
+ }
34
+ function hold(land) {
35
+ stopHold?.();
36
+ const view = viewport();
37
+ const content = view?.firstElementChild;
38
+ land();
39
+ if (!content) return;
40
+ const watcher = new ResizeObserver(land);
41
+ const timer = window.setTimeout(() => stop(), SETTLE_MS);
42
+ const stop = () => {
43
+ watcher.disconnect();
44
+ window.clearTimeout(timer);
45
+ for (const name of SETTLE_STOP) window.removeEventListener(name, stop, true);
46
+ if (stopHold === stop) stopHold = void 0;
47
+ };
48
+ for (const name of SETTLE_STOP) window.addEventListener(name, stop, { capture: true, passive: true });
49
+ watcher.observe(content);
50
+ stopHold = stop;
51
+ }
52
+ function arrive() {
53
+ const back = travelled;
54
+ travelled = false;
55
+ current = addressKey();
56
+ shown = path();
57
+ const view = viewport();
58
+ if (!view) return;
59
+ const saved = positions.get(current);
60
+ if (back && saved !== void 0) {
61
+ hold(() => view.scrollTo({ top: saved, behavior: "instant" }));
62
+ return;
63
+ }
64
+ if (hash()) {
65
+ hold(() => {
66
+ if (!reach(hash(), "instant")) view.scrollTo({ top: 0, behavior: "instant" });
67
+ });
68
+ return;
69
+ }
70
+ stopHold?.();
71
+ view.scrollTo({ top: 0, left: 0, behavior: "instant" });
72
+ }
73
+ function afterDialogs(run) {
74
+ const since = performance.now();
75
+ const mine = ++pending;
76
+ const tick = () => {
77
+ if (mine !== pending) return;
78
+ const locked = document.querySelector('[role="dialog"]') || getComputedStyle(document.body).overflow === "hidden";
79
+ if (locked && performance.now() - since < DIALOG_WAIT_MS) requestAnimationFrame(tick);
80
+ else run();
81
+ };
82
+ requestAnimationFrame(tick);
83
+ }
84
+ watch(
85
+ path,
86
+ () => {
87
+ const view = viewport();
88
+ if (view) positions.set(current, view.scrollTop);
89
+ void nextTick(() => requestAnimationFrame(arrive));
90
+ },
91
+ { flush: "pre" }
92
+ );
93
+ watch(hash, (next, previous) => {
94
+ if (next === previous) return;
95
+ if (path() !== shown) return;
96
+ afterDialogs(() => {
97
+ travelled = false;
98
+ if (!reach(next, "smooth")) viewport()?.scrollTo({ top: 0, behavior: "smooth" });
99
+ });
100
+ });
101
+ onMounted(() => {
102
+ if (hash()) requestAnimationFrame(() => hold(() => void reach(hash(), "instant")));
103
+ });
104
+ onBeforeUnmount(() => {
105
+ stopHold?.();
106
+ window.removeEventListener("popstate", onPop);
107
+ });
108
+ }
109
+ export {
110
+ useScrollMemory
111
+ };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ 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-DMSJg18Q.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-rr5kqiK3.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";
@@ -60,19 +60,19 @@ 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
62
  import { _ as _39 } from "./chunks/LpModal-BnjZ2iem.js";
63
- import { _ as _40 } from "./chunks/LpModalNav-CE3iLnxB.js";
63
+ import { _ as _40 } from "./chunks/LpModalNav-DQpV2kdn.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";
67
- import { _ as _44 } from "./chunks/LpNumberFlow-CcxEGUy0.js";
67
+ import { _ as _44 } from "./chunks/LpNumberFlow-Vqj0tP7B.js";
68
68
  import { _ as _45 } from "./chunks/LpOtpInput-CeBPsM7D.js";
69
69
  import { _ as _46 } from "./chunks/LpPagination-BIIiYHXR.js";
70
70
  import { _ as _47 } from "./chunks/LpPasswordInput-BkHKQDhM.js";
71
- import { _ as _48 } from "./chunks/LpPie-Mr3EtFEd.js";
71
+ import { _ as _48 } from "./chunks/LpPie-DrZepi0p.js";
72
72
  import { _ as _49 } from "./chunks/LpPhoneInput-CETEp5W7.js";
73
73
  import { filterCountries, flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
74
74
  import { _ as _50 } from "./chunks/LpPopover-DmZ3wUtP.js";
75
- import { _ as _51 } from "./chunks/LpProgress-rOlyafqp.js";
75
+ import { _ as _51 } from "./chunks/LpProgress-Yr6ONG5t.js";
76
76
  import { _ as _52 } from "./chunks/LpRadio-CWA5Eb3M.js";
77
77
  import { _ as _53 } from "./chunks/LpRadioGroup-ggaPL92T.js";
78
78
  import { _ as _54 } from "./chunks/LpRollingText-B54Pje7P.js";
@@ -86,26 +86,26 @@ 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-BciLMBgY.js";
89
+ import { _ as _63 } from "./chunks/LpSidebar-BoNLKgh-.js";
90
90
  import { _ as _64 } from "./chunks/LpSkeleton-BB6XIV-w.js";
91
91
  import { _ as _65 } from "./chunks/LpSlider-DxYoCJJO.js";
92
92
  import { _ as _66 } from "./chunks/LpRangeSlider-CM-0ZITZ.js";
93
93
  import { _ as _67 } from "./chunks/LpVideoBackdrop-DS1wGv-X.js";
94
94
  import { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop.js";
95
95
  import { _ as _68 } from "./chunks/LpSparkline-CExIRDb3.js";
96
- import { _ as _69 } from "./chunks/LpStat-D6V6Fdfc.js";
96
+ import { _ as _69 } from "./chunks/LpStat-BVf2UvEc.js";
97
97
  import { _ as _70 } from "./chunks/LpStepper-DpsocBNJ.js";
98
98
  import { _ as _71 } from "./chunks/LpSwitch-BnXd1llc.js";
99
99
  import { default as default12 } from "./components/LpTable.vue.js";
100
100
  import { compareValues } from "./components/tableSort.js";
101
101
  import { resolveRowMenu } from "./components/tableRowMenu.js";
102
- import { _ as _72 } from "./chunks/LpTableOfContents-CraicEs4.js";
102
+ import { _ as _72 } from "./chunks/LpTableOfContents-BN5zvpnx.js";
103
103
  import { default as default13 } from "./components/LpTabs.vue.js";
104
104
  import { _ as _73 } from "./chunks/LpBottomNav-CbNQQPuL.js";
105
105
  import { _ as _74 } from "./chunks/LpTagsInput-C-mDG648.js";
106
106
  import { addTags, splitTags } from "./components/tags.js";
107
107
  import { _ as _75 } from "./chunks/LpTextarea-TE-yZv2U.js";
108
- import { _ as _76 } from "./chunks/LpThemeSwitcher-BDrIJswA.js";
108
+ import { _ as _76 } from "./chunks/LpThemeSwitcher-BnEn_mvf.js";
109
109
  import { _ as _77 } from "./chunks/LpTilt-B7KHX1zH.js";
110
110
  import { _ as _78 } from "./chunks/LpToaster-IwTXjbbU.js";
111
111
  import { _ as _79 } from "./chunks/LpTooltip-BcBvIx4z.js";
@@ -116,7 +116,7 @@ import { _ as _82 } from "./chunks/LpHints-Bz-BlaSl.js";
116
116
  import { HINT_SIGNALS, contextMenuTip, createHintSystem, dropzoneTip, hintKey, hintSummary, hotkeyTip, localHintStorage, noteDropzone, noteHotkeyTarget, paletteTip, provideHintSystem, runningHintSystem, useHintSystem } from "./composables/useHintSystem.js";
117
117
  import { _ as _83 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
118
118
  import { _ as _84 } from "./chunks/LpTopologyCanvas-B1gIbS0n.js";
119
- import { _ as _85 } from "./chunks/LpUptimeBar-BtkZRwY0.js";
119
+ import { _ as _85 } from "./chunks/LpUptimeBar-DqNvvMBK.js";
120
120
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
121
121
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
122
122
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -124,7 +124,7 @@ import { layoutController, useLayout } from "./layout/useLayout.js";
124
124
  import { a as parseTheme, s as serializeTheme } from "./chunks/LpThemeProvider-8MG6yqU9.js";
125
125
  import { D, _ as _86, T, b as b2, c as c2, d, e, f, g, l, h, n, i as i2, j, p, r, k, t, m, o, q, u, v } from "./chunks/LpThemeProvider-8MG6yqU9.js";
126
126
  import { G, N, d as d2, i as i3, s } from "./chunks/backdrop-Dkf2vM81.js";
127
- import { _ as _87, a as a2, b as b3, c as c3, d as d3, S, e as e2, u as u2, w } from "./chunks/LpThemeStudio-BJDKf3_Z.js";
127
+ import { _ as _87, a as a2, b as b3, c as c3, d as d3, S, e as e2, u as u2, w } from "./chunks/LpThemeStudio-BpORDvH6.js";
128
128
  import { useClipboard } from "./composables/useClipboard.js";
129
129
  import { activeShortcuts, appleModifierLabel, findHotkeyConflicts, hotkeyConflicts, hotkeySteps, isAppleKeyboard, matchesHotkey, mergeShortcutGroups, parseCombo, pendingHotkey, sequenceProgress, useAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
130
130
  import { anyModalOpen, topModalElement, useModalLayer } from "./components/modalDepth.js";
@@ -164,8 +164,8 @@ import { _ as _88 } from "./chunks/LpFrame-BVQCQLDI.js";
164
164
  import { _ as _89 } from "./chunks/LpThemeStudioSection-B7bt8Iyg.js";
165
165
  import { isInternalHref, resolveLink } from "./components/link.js";
166
166
  import { _ as _90 } from "./chunks/LpRepeater-D7ZmnIYd.js";
167
- import { _ as _91 } from "./chunks/LpGauge-BO1PYYqD.js";
168
- import { _ as _92 } from "./chunks/LpQuota-B12yijdB.js";
167
+ import { _ as _91 } from "./chunks/LpGauge-KZaQ_FDR.js";
168
+ import { _ as _92 } from "./chunks/LpQuota-Dk2MyI2Z.js";
169
169
  import { _ as _93 } from "./chunks/LpStatus-DglxttQ7.js";
170
170
  import { _ as _94 } from "./chunks/LpRelativeTime-DwgCb5-8.js";
171
171
  import { _ as _95 } from "./chunks/LpActivityLine-CJvzrNpj.js";
@@ -181,14 +181,14 @@ import { childProgress, connectorFilled, currentStepIndex, isSettled, overallSta
181
181
  import { _ as _98 } from "./chunks/LpLoader-DVwkOmt9.js";
182
182
  import { useLoadingGate } from "./composables/useLoadingGate.js";
183
183
  import { useNow } from "./composables/useNow.js";
184
- import { _ as _99 } from "./chunks/LpCountdown-BxE7stYk.js";
184
+ import { _ as _99 } from "./chunks/LpCountdown-DgeY3cLG.js";
185
185
  import { DEFAULT_MILESTONES, DEFAULT_UNIT_LABELS, ceilToSecond, crossedMilestone, formatElapsed, formatTime, msToNextSecond, splitTime, timeParts } from "./components/timeSpan.js";
186
186
  import { _ as _100 } from "./chunks/LpCopyButton-xF7wFVoU.js";
187
187
  import { _ as _101 } from "./chunks/LpSecretField-BbmqM9ye.js";
188
188
  import { _ as _102 } from "./chunks/LpDataList-Jxyl9H5w.js";
189
189
  import { _ as _103 } from "./chunks/LpEditable-Cy6GLbFx.js";
190
190
  import { _ as _104 } from "./chunks/LpTruncate-CflAIwiJ.js";
191
- import { _ as _105 } from "./chunks/LpCodeDisplay-AQx-3nNb.js";
191
+ import { _ as _105 } from "./chunks/LpCodeDisplay-CE9TxCA4.js";
192
192
  import { formatRemaining, groupCode, middleTruncate, normalizeCode } from "./components/fieldText.js";
193
193
  import { _ as _106 } from "./chunks/LpHoverCard-spGxyw7T.js";
194
194
  import { _ as _107 } from "./chunks/LpIdenticon-BzdC_qz9.js";
@@ -214,10 +214,10 @@ import { _ as _116 } from "./chunks/LpSpotlight-4F3NeUMY.js";
214
214
  import { _ as _117 } from "./chunks/LpGlowBorder-B8V-QzIl.js";
215
215
  import { _ as _118 } from "./chunks/LpMagnetic-CtXKLa2Y.js";
216
216
  import { spotlightPoint, useSpotlight } from "./composables/useSpotlight.js";
217
- import { _ as _119 } from "./chunks/LpCommandScope-BlKcgBnK.js";
217
+ import { _ as _119 } from "./chunks/LpCommandScope-CtUhJe8T.js";
218
218
  import { createPaletteRegistry, mergePaletteCommands, usePaletteCommands, vPalette } from "./composables/usePaletteCommands.js";
219
- import { u as u3 } from "./chunks/palette-Cvxl_1tM.js";
220
- import { _ as _120 } from "./chunks/LpPaletteTheme-CdEEMMEF.js";
219
+ import { u as u3 } from "./chunks/palette-Co8tSASo.js";
220
+ import { _ as _120 } from "./chunks/LpPaletteTheme-DiLYKQiZ.js";
221
221
  const UI_CONFIG_VERSION = 1;
222
222
  function serializeConfig(config) {
223
223
  const out = {
@@ -3,10 +3,11 @@ import { type PaletteScope } from "../composables/usePaletteCommands";
3
3
  import type { ThemeStudio } from "./studio";
4
4
  import type { ThemeStudioPart } from "../components/LpThemeStudio.vue";
5
5
  /** Registers the studio itself; no second list of theme options in the app. */
6
- /** `hide` leaves out the same studio rows the app hides in its own studio. */
6
+ /** `hide` leaves out the same studio rows the app hides in its own studio; `keywords` adds the app's own search words. */
7
7
  export declare function usePaletteTheme(studio: MaybeRefOrGetter<ThemeStudio | undefined>, options?: {
8
8
  id?: string;
9
9
  label?: string;
10
10
  scope?: PaletteScope;
11
11
  hide?: ThemeStudioPart[];
12
+ keywords?: string[];
12
13
  }): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.72.5",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -33,6 +33,7 @@ export default { inheritAttrs: false }
33
33
  */
34
34
  import { computed, onMounted, ref, useId, watch } from "vue"
35
35
  import { useLabels } from "../composables/useLpConfig"
36
+ import { useScrollMemory } from "../composables/scrollMemory"
36
37
  import LpBottomNav from "./LpBottomNav.vue"
37
38
  import LpButton from "./LpButton.vue"
38
39
  import LpIcon from "./LpIcon.vue"
@@ -59,8 +60,18 @@ const props = withDefaults(
59
60
  * The current route path. Enables the built-in prefix-match active test and
60
61
  * the derived page title without the host wiring its own `isActive`. Pass
61
62
  * `route.path`. Ignored when a custom `isActive` is provided.
63
+ *
64
+ * A new path also places the page in the shell's scroll region: back and
65
+ * forward return to where it was left, a `hash` lands on that anchor
66
+ * (honouring its `scroll-margin-top`), anything else starts at the top.
62
67
  */
63
68
  path?: string
69
+ /**
70
+ * The current route hash (`route.hash`). With `path` it lands a page on
71
+ * its anchor; a new hash on the same path glides there, after a dialog
72
+ * that started the jump has closed.
73
+ */
74
+ hash?: string
64
75
  /**
65
76
  * Custom active test (router-driven nav). Overrides `path`/`modelValue`.
66
77
  */
@@ -177,7 +188,8 @@ const text = useLabels(
177
188
  /*
178
189
  * The page scrolls inside the shell's own region, not the window, so the
179
190
  * router's scroll behaviour never reaches it: without this a route change from
180
- * a scrolled page landed mid-way down the next one.
191
+ * a scrolled page landed mid-way down the next one, and back never returned.
192
+ * A full-bleed page owns its scrolling, so the shell leaves it alone.
181
193
  */
182
194
  const scrollArea = ref<{ viewportEl: HTMLElement | null } | null>(null)
183
195
  const bleedId = `lp-shell-bleed-${useId()}`
@@ -195,9 +207,13 @@ watch(
195
207
  () => props.path,
196
208
  () => {
197
209
  if (drawerOpen.value) drawerOpen.value = false
198
- scrollArea.value?.viewportEl?.scrollTo({ top: 0, left: 0, behavior: "instant" })
199
210
  },
200
211
  )
212
+ useScrollMemory(
213
+ () => (bleeding.value ? null : scrollArea.value?.viewportEl),
214
+ () => props.path,
215
+ () => props.hash,
216
+ )
201
217
 
202
218
  function onSelect(item: SidebarItem) {
203
219
  emit("select", item)
@@ -226,7 +226,7 @@ onBeforeUnmount(stop)
226
226
 
227
227
  /*
228
228
  * The app's locale (reka's ConfigProvider) when the counter has none of its
229
- * own, the same rule the dates follow: a Russian app read "4,594" otherwise.
229
+ * own, the same rule the dates follow: a French app read "4,594" otherwise.
230
230
  */
231
231
  const locale = useLocale(() => props.locale)
232
232
  // The grouped and compact forms are written in the locale; the plain one always uses ".".
@@ -250,7 +250,7 @@ function formatAt(v: number, padded: boolean): string {
250
250
  * The decimal count is what stays fixed: one place whenever a unit is in
251
251
  * play, so the row's shape only changes at a rollover, not on every frame.
252
252
  * Written in the app's locale: the cells find the fraction by that
253
- * locale's own mark (fractionMark), so "1,2 тыс." keeps its columns.
253
+ * locale's own mark (fractionMark), so "1,2 k" keeps its columns.
254
254
  */
255
255
  return compactNumber(v, { plainDecimals: 0, fixed: true, locale: locale.value, words: true })
256
256
  }
@@ -492,7 +492,7 @@ const label = computed(
492
492
  /* Faded, so a held-open column reads as an empty wheel rather than a zero the
493
493
  number actually has. */
494
494
  /*
495
- * A space separator (ru, fr, …) takes a whole column in a monospaced or
495
+ * A space separator (fr, pl, …) takes a whole column in a monospaced or
496
496
  * tabular face, so "7 271" read as "7 271". It is drawn as the narrow gap
497
497
  * the locale means.
498
498
  */
@@ -147,7 +147,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
147
147
  </span>
148
148
  </div>
149
149
  <!-- A narrow tile moves the delta under the figure rather than breaking the
150
- figure itself: "2085 ч" split across two lines reads as two values. -->
150
+ figure itself: "2085 h" split across two lines reads as two values. -->
151
151
  <div class="flex flex-wrap items-baseline gap-x-2">
152
152
  <!-- The placeholder sits INSIDE the value's own type size, so its line box
153
153
  is the real one: the tile cannot change height when the figure lands. -->
@@ -12,7 +12,7 @@
12
12
  import { Motion } from "motion-v"
13
13
  import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
14
14
  import { useLabels } from "../composables/useLpConfig"
15
- import { atScrollEnd, scrollParent } from "../composables/scroll"
15
+ import { atScrollEnd, scrollMarginTop, scrollParent, scrollToElement } from "../composables/scroll"
16
16
  import { usePillTransition } from "../composables/usePillTransition"
17
17
 
18
18
  export interface TocLink {
@@ -170,7 +170,9 @@ function onClick(id: string, ev: MouseEvent) {
170
170
  ev.preventDefault()
171
171
  activeId.value = id // mark immediately so the bar jumps to the clicked item
172
172
  suppressSpyUntilScrollEnds(el)
173
- el.scrollIntoView({ behavior: "smooth", block: "start" })
173
+ // Only the nearest scrolling box moves: scrollIntoView also shifted every
174
+ // overflow-hidden ancestor, the shell itself among them.
175
+ scrollToElement(el, { block: "start", behavior: "smooth", margin: scrollMarginTop(el) })
174
176
  // Keep the router's own state: replacing it with null broke scroll
175
177
  // restoration and back/forward bookkeeping in vue-router apps.
176
178
  history.replaceState(history.state, "", `#${id}`)
@@ -81,21 +81,32 @@ function rankExact<T extends SearchableCommand>(items: T[], terms: CommandSearch
81
81
  }
82
82
 
83
83
  const LATIN_KEYS = "`qwertyuiop[]asdfghjkl;'zxcvbnm,."
84
- const CYRILLIC_KEYS = "ёйцукенгшщзхъфывапролджэячсмитьбю"
85
- const LAYOUT_KEYS = new Map([...LATIN_KEYS].flatMap((key, i) => [[key, CYRILLIC_KEYS[i]!], [CYRILLIC_KEYS[i]!, key]]))
84
+ const CYRILLIC_LAYOUTS = ["ґйцукенгшщзхїфівапролджєячсмитьбю", "ёйцукенгшщзхъфывапролджэячсмитьбю"]
85
+ const LAYOUT_MAPS = CYRILLIC_LAYOUTS.map(keys => new Map([...LATIN_KEYS].flatMap((key, i) => [[key, keys[i]!], [keys[i]!, key]])))
86
86
 
87
- export function swapCommandLayout(value: string): string {
87
+ function swapWith(value: string, keys: Map<string, string>): string {
88
88
  return [...value].map(char => {
89
- const replacement = LAYOUT_KEYS.get(char.toLowerCase())
89
+ const replacement = keys.get(char.toLowerCase())
90
90
  return replacement ? char === char.toUpperCase() ? replacement.toUpperCase() : replacement : char
91
91
  }).join("")
92
92
  }
93
93
 
94
+ /** The query as if typed on the other layout: Latin to Ukrainian by default, `layout` 1 for the other Cyrillic one. */
95
+ export function swapCommandLayout(value: string, layout = 0): string {
96
+ return swapWith(value, LAYOUT_MAPS[layout] ?? LAYOUT_MAPS[0]!)
97
+ }
98
+
94
99
  export function searchCommandItems<T extends SearchableCommand>(items: T[], terms: CommandSearchTerms): { items: T[]; terms: string[] } {
95
100
  const exact = rankExact(items, terms)
96
101
  if (exact.length || !terms.include.length) return { items: exact, terms: terms.include }
97
- const alternate = { include: terms.include.map(swapCommandLayout), exclude: terms.exclude, groups: terms.groups }
98
- return { items: rankExact(items, alternate), terms: alternate.include }
102
+ let fallback = terms.include
103
+ for (const keys of LAYOUT_MAPS) {
104
+ const alternate = { include: terms.include.map(term => swapWith(term, keys)), exclude: terms.exclude, groups: terms.groups }
105
+ const found = rankExact(items, alternate)
106
+ if (found.length) return { items: found, terms: alternate.include }
107
+ if (fallback === terms.include) fallback = alternate.include
108
+ }
109
+ return { items: [], terms: fallback }
99
110
  }
100
111
 
101
112
  export function rankCommandItems<T extends SearchableCommand>(items: T[], terms: CommandSearchTerms): T[] {
@@ -35,7 +35,7 @@ export interface FormatBytesOptions {
35
35
  /**
36
36
  * A byte count in the largest unit that keeps it readable: 1 536 → "1.5 kB".
37
37
  * Binary steps, the way file managers and storage limits show sizes. Unit
38
- * names come from Intl, so a Russian app reads "1,5 МБ" without a table of
38
+ * names come from Intl, so a French app reads "1,5 Mo" without a table of
39
39
  * its own. One decimal under 10 (2.4 MB), none above (240 MB). Negative or
40
40
  * non-finite input is "—": there is no size to show.
41
41
  */
@@ -54,7 +54,7 @@ export function formatBytes(bytes: number, locale?: string, options: FormatBytes
54
54
  // Intl's "short" form spells the smallest unit out ("1023 byte", "1023 Byte")
55
55
  // while every other unit is a symbol, and its "narrow" form glues English
56
56
  // symbols to the figure ("1023B"). Narrow symbols with one no-break space
57
- // read the same in every locale: "1023 B", "1.5 kB", "1,5 МБ".
57
+ // read the same in every locale: "1023 B", "1.5 kB", "1,5 Mo".
58
58
  const parts = numberFormat(locale, {
59
59
  style: "unit",
60
60
  unit: BYTE_UNITS[step],
@@ -79,8 +79,8 @@ const COMPACT_UNITS: readonly (readonly [number, string])[] = [
79
79
  ]
80
80
 
81
81
  /*
82
- * The unit word and the gap before it as the locale writes them: "тыс." with a
83
- * no-break space in Russian, "K" glued on in English. Taken from Intl's compact
82
+ * The unit word and the gap before it as the locale writes them: "Md" with a
83
+ * no-break space in French, "K" glued on in English. Taken from Intl's compact
84
84
  * notation rather than a table, so any locale reads its own.
85
85
  */
86
86
  const compactSuffixes = new Map<string, string>()
@@ -99,14 +99,14 @@ function compactSuffix(locale: string, step: number): string {
99
99
 
100
100
  export interface CompactNumberOptions {
101
101
  /**
102
- * Writes the decimal separator the locale's way ("1,2k" in Russian). The
102
+ * Writes the decimal separator the locale's way ("1,2k" in French). The
103
103
  * k/M/B/T suffixes stay: Intl's own compact notation changes the shape of
104
- * the figure per locale ("1,2 тыс."), which a tick label cannot absorb.
104
+ * the figure per locale ("1,2 k" in French), which a tick label cannot absorb.
105
105
  * Unset keeps a plain "." whatever the browser says.
106
106
  */
107
107
  locale?: string
108
108
  /**
109
- * With a locale, write the unit as that locale does: "11,9 тыс." in Russian.
109
+ * With a locale, write the unit as that locale does: "13,1 Md" in French.
110
110
  * For a figure read on its own (a stat tile, a counter), where "11,9k" looks
111
111
  * foreign; not for axis ticks, where the longer word crowds the axis.
112
112
  */
@@ -124,7 +124,7 @@ export interface CompactNumberOptions {
124
124
 
125
125
  /**
126
126
  * A figure shortened with k/M/B/T: 1 200 → "1.2k", 3 400 000 → "3.4M"; with
127
- * `words` and a locale in that locale's own words, "1,2 тыс." in Russian.
127
+ * `words` and a locale in that locale's own words, "13,1 Md" in French.
128
128
  *
129
129
  * The unit is picked by what will PRINT, not by the raw size: 999 950 at one
130
130
  * place rounds to "1000k", which reads as the next unit up, so it becomes
@@ -163,7 +163,7 @@ export function compactNumber(value: number, options: CompactNumberOptions = {})
163
163
 
164
164
  /**
165
165
  * The character a locale writes between the whole and the fractional part:
166
- * "." in English, "," in Russian and German. A counter that splits a grouped
166
+ * "." in English, "," in French and German. A counter that splits a grouped
167
167
  * figure into columns has to find the fraction by this mark, not by ".",
168
168
  * because in German "." is the thousands separator.
169
169
  */
@@ -65,7 +65,7 @@ export function quotaState(
65
65
 
66
66
  /**
67
67
  * An amount in its unit, locale-aware. Bytes go through `formatBytes`, whose
68
- * unit names come from Intl, so a Russian app reads "1,5 ГБ" without a
68
+ * unit names come from Intl, so a French app reads "1,5 Go" without a
69
69
  * translation table of its own.
70
70
  */
71
71
  export function formatAmount(value: number, unit?: QuotaUnit, locale?: string): string {
@@ -19,7 +19,7 @@ export interface TimeSplit {
19
19
 
20
20
  export type TimeUnit = "d" | "h" | "m" | "s"
21
21
 
22
- /** Unit suffixes, so an app can write "2 д 3 ч" without reformatting. */
22
+ /** Unit suffixes, so an app can write "2 j 3 h" without reformatting. */
23
23
  export type TimeUnitLabels = Record<TimeUnit, string>
24
24
 
25
25
  export const DEFAULT_UNIT_LABELS: TimeUnitLabels = { d: "d", h: "h", m: "m", s: "s" }
@@ -46,6 +46,11 @@ export interface ScrollToElementOptions {
46
46
  }
47
47
 
48
48
  /** Scroll the nearest scroller, and only it, so that `el` is in view. */
49
+ /** The element's own `scroll-margin-top`, the gap it asks to keep above it when scrolled to. */
50
+ export function scrollMarginTop(el: Element): number {
51
+ return Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0
52
+ }
53
+
49
54
  export function scrollToElement(
50
55
  el: Element | null | undefined,
51
56
  { block = "nearest", behavior = "auto", margin = 0 }: ScrollToElementOptions = {},
@@ -0,0 +1,145 @@
1
+ import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
2
+ import { scrollMarginTop } from "./scroll"
3
+
4
+ /*
5
+ * Where a page lands when the shell's own scroll region changes page. The
6
+ * region is not the window, so a router's scrollBehavior never reaches it, and
7
+ * the shell has to answer the questions a browser answers for a plain site:
8
+ *
9
+ * - back and forward return to where the page was left;
10
+ * - an address with a hash lands on that anchor, and stays on it while the
11
+ * page is still growing (a lazily loaded body, images), until the reader
12
+ * scrolls or a moment has passed;
13
+ * - a new hash on the same page glides there, once a dialog that started the
14
+ * jump (a palette, a menu) has let go of the page;
15
+ * - anything else starts at the top.
16
+ *
17
+ * The router is not needed: what changed is read from the shell's `path` and
18
+ * `hash` props, history position from `popstate`, and positions are keyed by
19
+ * the address the browser shows.
20
+ */
21
+
22
+ const SETTLE_MS = 1500
23
+ const DIALOG_WAIT_MS = 2000
24
+ const SETTLE_STOP = ["wheel", "touchstart", "keydown", "pointerdown"] as const
25
+
26
+ const positions = new Map<string, number>()
27
+
28
+ function addressKey(): string {
29
+ return `${location.pathname}${location.search}`
30
+ }
31
+
32
+ export function useScrollMemory(
33
+ viewport: () => HTMLElement | null | undefined,
34
+ path: () => string | undefined,
35
+ hash: () => string | undefined,
36
+ ): void {
37
+ if (typeof window === "undefined") return
38
+ let current = addressKey()
39
+ let shown = path()
40
+ let travelled = false
41
+ let stopHold: (() => void) | undefined
42
+ let pending = 0
43
+
44
+ const onPop = () => {
45
+ travelled = true
46
+ }
47
+ window.addEventListener("popstate", onPop)
48
+
49
+ function anchor(value: string | undefined): HTMLElement | null {
50
+ if (!value || value === "#") return null
51
+ const id = decodeURIComponent(value.replace(/^#/, ""))
52
+ return document.getElementById(id)
53
+ }
54
+
55
+ function reach(value: string | undefined, behavior: ScrollBehavior): boolean {
56
+ const view = viewport()
57
+ const target = anchor(value)
58
+ if (!view || !target || !view.contains(target)) return false
59
+ const top = target.getBoundingClientRect().top - view.getBoundingClientRect().top + view.scrollTop - scrollMarginTop(target)
60
+ view.scrollTo({ top: Math.max(0, top), behavior })
61
+ return true
62
+ }
63
+
64
+ // Re-applies a landing while the page below it keeps changing size.
65
+ function hold(land: () => void) {
66
+ stopHold?.()
67
+ const view = viewport()
68
+ const content = view?.firstElementChild
69
+ land()
70
+ if (!content) return
71
+ const watcher = new ResizeObserver(land)
72
+ const timer = window.setTimeout(() => stop(), SETTLE_MS)
73
+ const stop = () => {
74
+ watcher.disconnect()
75
+ window.clearTimeout(timer)
76
+ for (const name of SETTLE_STOP) window.removeEventListener(name, stop, true)
77
+ if (stopHold === stop) stopHold = undefined
78
+ }
79
+ for (const name of SETTLE_STOP) window.addEventListener(name, stop, { capture: true, passive: true })
80
+ watcher.observe(content)
81
+ stopHold = stop
82
+ }
83
+
84
+ function arrive() {
85
+ const back = travelled
86
+ travelled = false
87
+ current = addressKey()
88
+ shown = path()
89
+ const view = viewport()
90
+ if (!view) return
91
+ const saved = positions.get(current)
92
+ if (back && saved !== undefined) {
93
+ hold(() => view.scrollTo({ top: saved, behavior: "instant" }))
94
+ return
95
+ }
96
+ if (hash()) {
97
+ hold(() => {
98
+ if (!reach(hash(), "instant")) view.scrollTo({ top: 0, behavior: "instant" })
99
+ })
100
+ return
101
+ }
102
+ stopHold?.()
103
+ view.scrollTo({ top: 0, left: 0, behavior: "instant" })
104
+ }
105
+
106
+ function afterDialogs(run: () => void) {
107
+ const since = performance.now()
108
+ const mine = ++pending
109
+ const tick = () => {
110
+ if (mine !== pending) return
111
+ const locked = document.querySelector('[role="dialog"]') || getComputedStyle(document.body).overflow === "hidden"
112
+ if (locked && performance.now() - since < DIALOG_WAIT_MS) requestAnimationFrame(tick)
113
+ else run()
114
+ }
115
+ requestAnimationFrame(tick)
116
+ }
117
+
118
+ watch(
119
+ path,
120
+ () => {
121
+ const view = viewport()
122
+ if (view) positions.set(current, view.scrollTop)
123
+ void nextTick(() => requestAnimationFrame(arrive))
124
+ },
125
+ { flush: "pre" },
126
+ )
127
+
128
+ watch(hash, (next, previous) => {
129
+ if (next === previous) return
130
+ if (path() !== shown) return
131
+ afterDialogs(() => {
132
+ travelled = false
133
+ if (!reach(next, "smooth")) viewport()?.scrollTo({ top: 0, behavior: "smooth" })
134
+ })
135
+ })
136
+
137
+ onMounted(() => {
138
+ if (hash()) requestAnimationFrame(() => hold(() => void reach(hash(), "instant")))
139
+ })
140
+
141
+ onBeforeUnmount(() => {
142
+ stopHold?.()
143
+ window.removeEventListener("popstate", onPop)
144
+ })
145
+ }