@leavepulse/ui 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{LpCommandPalette-DDJKICKh.css → LpCommandPalette-DCZB4EAf.css} +19 -19
- package/dist/chunks/{LpAppShell-InIK01Nn.js → LpAppShell-BBINfN1v.js} +1 -1
- package/dist/chunks/{LpPageScrollbar-D2O0xhJw.js → LpPageScrollbar-C9sTeU-2.js} +3 -8
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +22 -1
- package/dist/components/LpCommandPalette.vue.js +36 -15
- package/dist/components/LpPageScrollbar.vue.js +1 -1
- package/dist/components/commandPalette.d.ts +14 -0
- package/dist/components/commandPalette.js +42 -0
- package/dist/components/relativeTime.js +6 -1
- package/dist/composables/overlayHistory.js +16 -2
- package/dist/composables/useAsyncTask.d.ts +6 -1
- package/dist/composables/useAsyncTask.js +4 -1
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +69 -14
- package/src/components/LpPageScrollbar.vue +13 -11
- package/src/components/commandPalette.ts +62 -0
- package/src/components/relativeTime.ts +13 -1
- package/src/composables/overlayHistory.ts +29 -2
- package/src/composables/useAsyncTask.ts +10 -2
|
@@ -5,34 +5,34 @@
|
|
|
5
5
|
* pane from its right edge as it grows. Animating the columns themselves made
|
|
6
6
|
* every line of the list and the pane re-wrap on every frame.
|
|
7
7
|
*/
|
|
8
|
-
.lp-cmd-dialog[data-v-
|
|
8
|
+
.lp-cmd-dialog[data-v-b72dc3e1] {
|
|
9
9
|
--lp-cmd-list: min(92vw, 40rem);
|
|
10
10
|
--lp-cmd-pane: max(0px, min(26rem, 96vw - var(--lp-cmd-list)));
|
|
11
11
|
width: var(--lp-cmd-list);
|
|
12
12
|
}
|
|
13
|
-
.lp-cmd-split[data-v-
|
|
13
|
+
.lp-cmd-split[data-v-b72dc3e1] {
|
|
14
14
|
transition:
|
|
15
15
|
width 300ms var(--ease-emphasized, ease),
|
|
16
16
|
min-height 300ms var(--ease-emphasized, ease);
|
|
17
17
|
}
|
|
18
18
|
@media (min-width: 768px) {
|
|
19
|
-
.lp-cmd-split .lp-cmd-list[data-v-
|
|
19
|
+
.lp-cmd-split .lp-cmd-list[data-v-b72dc3e1] {
|
|
20
20
|
flex: 0 0 auto;
|
|
21
21
|
/* the dialog's 1px border on each side */
|
|
22
22
|
width: calc(var(--lp-cmd-list) - 2px);
|
|
23
23
|
}
|
|
24
|
-
.lp-cmd-dialog[data-pane="open"][data-v-
|
|
24
|
+
.lp-cmd-dialog[data-pane="open"][data-v-b72dc3e1] {
|
|
25
25
|
width: calc(var(--lp-cmd-list) + var(--lp-cmd-pane));
|
|
26
26
|
min-height: min(76vh, 36rem);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
.lp-cmd-pane[data-v-
|
|
29
|
+
.lp-cmd-pane[data-v-b72dc3e1] {
|
|
30
30
|
width: var(--lp-cmd-pane);
|
|
31
31
|
}
|
|
32
|
-
.lp-cmd-dialog[data-editing] .lp-cmd-pane[data-v-
|
|
32
|
+
.lp-cmd-dialog[data-editing] .lp-cmd-pane[data-v-b72dc3e1] {
|
|
33
33
|
width: 100%;
|
|
34
34
|
}
|
|
35
|
-
.lp-cmd-dialog[data-editing][data-v-
|
|
35
|
+
.lp-cmd-dialog[data-editing][data-v-b72dc3e1] {
|
|
36
36
|
width: var(--lp-cmd-list);
|
|
37
37
|
min-height: 0;
|
|
38
38
|
}
|
|
@@ -40,43 +40,43 @@
|
|
|
40
40
|
* The pane rides in from the right edge while the dialog opens up to it, and
|
|
41
41
|
* leaves the same way, for as long as the dialog takes to close over it.
|
|
42
42
|
*/
|
|
43
|
-
.lp-cmd-pane-enter-active[data-v-
|
|
44
|
-
.lp-cmd-pane-leave-active[data-v-
|
|
43
|
+
.lp-cmd-pane-enter-active[data-v-b72dc3e1],
|
|
44
|
+
.lp-cmd-pane-leave-active[data-v-b72dc3e1] {
|
|
45
45
|
transition:
|
|
46
46
|
transform 300ms var(--ease-emphasized, ease),
|
|
47
47
|
opacity 300ms ease;
|
|
48
48
|
}
|
|
49
|
-
.lp-cmd-pane-enter-from[data-v-
|
|
50
|
-
.lp-cmd-pane-leave-to[data-v-
|
|
49
|
+
.lp-cmd-pane-enter-from[data-v-b72dc3e1],
|
|
50
|
+
.lp-cmd-pane-leave-to[data-v-b72dc3e1] {
|
|
51
51
|
opacity: 0;
|
|
52
52
|
transform: translateX(40%);
|
|
53
53
|
}
|
|
54
|
-
.lp-cmd-para[data-v-
|
|
54
|
+
.lp-cmd-para[data-v-b72dc3e1] {
|
|
55
55
|
--rise: 4px;
|
|
56
56
|
animation: rise-in 260ms ease-out both;
|
|
57
57
|
}
|
|
58
58
|
@media (prefers-reduced-motion: reduce) {
|
|
59
|
-
.lp-cmd-split[data-v-
|
|
60
|
-
.lp-cmd-pane-enter-active[data-v-
|
|
61
|
-
.lp-cmd-pane-leave-active[data-v-
|
|
59
|
+
.lp-cmd-split[data-v-b72dc3e1],
|
|
60
|
+
.lp-cmd-pane-enter-active[data-v-b72dc3e1],
|
|
61
|
+
.lp-cmd-pane-leave-active[data-v-b72dc3e1] {
|
|
62
62
|
transition: none;
|
|
63
63
|
}
|
|
64
|
-
.lp-cmd-para[data-v-
|
|
64
|
+
.lp-cmd-para[data-v-b72dc3e1] {
|
|
65
65
|
animation: none;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
.lp-cmd-excerpt[data-v-
|
|
68
|
+
.lp-cmd-excerpt[data-v-b72dc3e1] {
|
|
69
69
|
display: -webkit-box;
|
|
70
70
|
-webkit-box-orient: vertical;
|
|
71
71
|
overflow: hidden;
|
|
72
72
|
}
|
|
73
73
|
/* Each result eases in; honours reduced motion. */
|
|
74
|
-
.lp-cmd-item[data-v-
|
|
74
|
+
.lp-cmd-item[data-v-b72dc3e1] {
|
|
75
75
|
--rise: 3px;
|
|
76
76
|
animation: rise-in 140ms ease-out both;
|
|
77
77
|
}
|
|
78
78
|
@media (prefers-reduced-motion: reduce) {
|
|
79
|
-
.lp-cmd-item[data-v-
|
|
79
|
+
.lp-cmd-item[data-v-b72dc3e1] {
|
|
80
80
|
animation: none;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -10,7 +10,7 @@ import { _ as _sfc_main$9 } from "./LpBottomNav-4I89sPDZ.js";
|
|
|
10
10
|
import { _ as _sfc_main$2 } from "./LpContextSwitcher-CGr6ZAuq.js";
|
|
11
11
|
import { _ as _sfc_main$3 } from "./LpButton-DSHioIfD.js";
|
|
12
12
|
import { _ as _sfc_main$4 } from "./LpIcon-BCQLuHSP.js";
|
|
13
|
-
import { _ as _sfc_main$8 } from "./LpPageScrollbar-
|
|
13
|
+
import { _ as _sfc_main$8 } from "./LpPageScrollbar-C9sTeU-2.js";
|
|
14
14
|
import { _ as _sfc_main$7 } from "./LpScrollArea-CqvwfZs2.js";
|
|
15
15
|
import { _ as _sfc_main$5 } from "./LpShift-DAgS_d2x.js";
|
|
16
16
|
import { _ as _sfc_main$1 } from "./LpSidebar-D3S8rYFf.js";
|
|
@@ -29,13 +29,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
29
29
|
function scroller() {
|
|
30
30
|
return doc?.scrollingElement ?? null;
|
|
31
31
|
}
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
probe.style.cssText = "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll";
|
|
35
|
-
d.body.append(probe);
|
|
36
|
-
const width = probe.offsetWidth - probe.clientWidth;
|
|
37
|
-
probe.remove();
|
|
38
|
-
return width;
|
|
32
|
+
function finePointer(d) {
|
|
33
|
+
return nodeWindow(d).matchMedia?.("(pointer: fine)").matches ?? false;
|
|
39
34
|
}
|
|
40
35
|
function measure() {
|
|
41
36
|
frame = 0;
|
|
@@ -107,7 +102,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
107
102
|
onMounted(() => {
|
|
108
103
|
const host = barEl.value ?? void 0;
|
|
109
104
|
doc = host?.ownerDocument;
|
|
110
|
-
if (!doc ||
|
|
105
|
+
if (!doc || !finePointer(doc)) return;
|
|
111
106
|
active.value = true;
|
|
112
107
|
releaseNative = hideNativeBar(doc);
|
|
113
108
|
const win = nodeWindow(doc);
|
|
@@ -188,8 +188,27 @@ export interface CommandPrompt {
|
|
|
188
188
|
* after the palette closed, is discarded and never reaches the list, so a
|
|
189
189
|
* fetcher does not need to check the signal itself. Checking it before an
|
|
190
190
|
* expensive step is only a saving.
|
|
191
|
+
*
|
|
192
|
+
* A fetcher that gathers from sources of different speeds (a local index, a
|
|
193
|
+
* database, a slow remote) can return an async iterable instead — an async
|
|
194
|
+
* generator is the easy way — and yield as it goes, so the quick sources show
|
|
195
|
+
* without waiting for the slow one. Each batch is the whole list so far and
|
|
196
|
+
* replaces the one before; the last is the answer. The search keeps its
|
|
197
|
+
* loading bar until the iterable ends, the highlight stays on the row it was
|
|
198
|
+
* on as rows are added, and when the query moves on the signal aborts and the
|
|
199
|
+
* iterable is closed (`return()`) — a generator parked in an `await` only
|
|
200
|
+
* closes at its next `yield`, running its `finally` there, so pass the signal
|
|
201
|
+
* on to what it awaits. A throw after some batches keeps them on screen, with
|
|
202
|
+
* a line saying the rest could not be fetched:
|
|
203
|
+
*
|
|
204
|
+
* async function* search(q, signal) {
|
|
205
|
+
* const local = index.find(q)
|
|
206
|
+
* yield local
|
|
207
|
+
* const remote = await api.search(q, { signal })
|
|
208
|
+
* yield [...local, ...remote]
|
|
209
|
+
* }
|
|
191
210
|
*/
|
|
192
|
-
export type CommandFetcher = (query: string, signal: AbortSignal, parsed: PaletteQuery) => Command[] | Promise<Command[]>;
|
|
211
|
+
export type CommandFetcher = (query: string, signal: AbortSignal, parsed: PaletteQuery) => Command[] | Promise<Command[]> | AsyncIterable<Command[]>;
|
|
193
212
|
/** Footer hints and the mode chip, spelled out so an app can translate them. */
|
|
194
213
|
export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
195
214
|
navigate: string;
|
|
@@ -209,6 +228,8 @@ export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
|
209
228
|
searching: string;
|
|
210
229
|
/** Shown when `search` (or a fetching mode) threw. */
|
|
211
230
|
searchFailed: string;
|
|
231
|
+
/** Shown under streamed results when the fetcher threw after some of them; defaults to "Some results could not be loaded." */
|
|
232
|
+
searchIncomplete?: string;
|
|
212
233
|
retry: string;
|
|
213
234
|
/** The row that unfolds a group cut by `groupLimit`; `{count}` is how many are hidden. */
|
|
214
235
|
showMore: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, computed, h, shallowRef, ref, watch, useId, useSlots, onBeforeUnmount, nextTick, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, normalizeClass, withModifiers, createTextVNode, toDisplayString, createElementVNode, createElementBlock, Fragment, renderList,
|
|
1
|
+
import { defineComponent, computed, h, shallowRef, ref, watch, useId, useSlots, onBeforeUnmount, nextTick, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, normalizeClass, withModifiers, createTextVNode, toDisplayString, createElementVNode, createCommentVNode, createElementBlock, Fragment, renderList, withDirectives, withKeys, vModelText, resolveDynamicComponent, vShow, Transition, renderSlot } from "vue";
|
|
2
2
|
import { useModelOverlayHistory } from "../composables/useOverlayHistory.js";
|
|
3
3
|
import { nodeWindow } from "../composables/realm.js";
|
|
4
4
|
import { clamp } from "../composables/clamp.js";
|
|
@@ -23,10 +23,10 @@ import { runningHintSystem, HINT_SIGNALS } from "../composables/useHintSystem.js
|
|
|
23
23
|
import { useModalLayer } from "./modalDepth.js";
|
|
24
24
|
import { useLabels } from "../composables/useLpConfig.js";
|
|
25
25
|
import { parsePaletteQuery, parseCommandQuery, searchCommandItems, applyQueryFilters, highlightParts } from "./commandSearch.js";
|
|
26
|
-
import { PALETTE_MODES, modeChipLabel, fallbackRows, readRecent, resolveRecentItems, recentVisible, commandHasPreview, matchModePrefix, isSafeHref, absoluteHref, commandKey, recentKey, pushRecent, moreRowId, toRecentItem } from "./commandPalette.js";
|
|
26
|
+
import { PALETTE_MODES, modeChipLabel, settleFetched, fallbackRows, readRecent, resolveRecentItems, recentVisible, commandHasPreview, matchModePrefix, isSafeHref, absoluteHref, commandKey, recentKey, pushRecent, moreRowId, toRecentItem } from "./commandPalette.js";
|
|
27
27
|
import { useView, usePortalTarget, useOutsideGuard, viewWindow } from "../composables/useView.js";
|
|
28
28
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
29
|
-
import '../assets/LpCommandPalette-
|
|
29
|
+
import '../assets/LpCommandPalette-DCZB4EAf.css';const _hoisted_1 = { class: "relative flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
|
|
30
30
|
const _hoisted_2 = ["title", "onClick"];
|
|
31
31
|
const _hoisted_3 = ["readonly", "aria-label", "aria-expanded", "aria-controls", "aria-activedescendant", "placeholder", "onKeydown"];
|
|
32
32
|
const _hoisted_4 = { class: "shrink-0 rounded-small border border-line px-1.5 py-0.5 text-[10px] text-muted" };
|
|
@@ -190,6 +190,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
190
190
|
back: "back",
|
|
191
191
|
searching: "Searching…",
|
|
192
192
|
searchFailed: "Search is unavailable right now.",
|
|
193
|
+
searchIncomplete: "Some results could not be loaded.",
|
|
193
194
|
retry: "Retry",
|
|
194
195
|
showMore: "Show {count} more",
|
|
195
196
|
actions: "actions",
|
|
@@ -342,24 +343,34 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
342
343
|
task.cancel();
|
|
343
344
|
}
|
|
344
345
|
const failed = ref(false);
|
|
346
|
+
const incomplete = ref(false);
|
|
345
347
|
function refresh(keepRows = false) {
|
|
346
348
|
cancel();
|
|
347
349
|
if (!keepRows) found.value = [];
|
|
348
350
|
failed.value = false;
|
|
351
|
+
incomplete.value = false;
|
|
349
352
|
const run2 = fetcher();
|
|
350
353
|
if (!run2) return;
|
|
351
354
|
const parsed = parsePaletteQuery(query.value.trim());
|
|
352
355
|
const q = parsed.text;
|
|
353
356
|
const inMode = mode.value !== null;
|
|
354
357
|
if (!inMode && q.length < Math.max(1, props.minChars)) return;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
358
|
+
let streamed = false;
|
|
359
|
+
task.run(
|
|
360
|
+
(signal, emit2) => settleFetched(run2(q, signal, parsed), signal, (rows) => {
|
|
361
|
+
streamed = true;
|
|
362
|
+
emit2(rows);
|
|
363
|
+
}),
|
|
364
|
+
{
|
|
365
|
+
delay: props.debounce,
|
|
366
|
+
apply: (hits) => found.value = hits,
|
|
367
|
+
fail: () => {
|
|
368
|
+
if (!streamed) found.value = [];
|
|
369
|
+
incomplete.value = streamed;
|
|
370
|
+
failed.value = true;
|
|
371
|
+
}
|
|
361
372
|
}
|
|
362
|
-
|
|
373
|
+
);
|
|
363
374
|
}
|
|
364
375
|
const source = computed(() => mode.value ? modeItems.value : rootCommands.value);
|
|
365
376
|
const local = computed(() => {
|
|
@@ -851,6 +862,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
851
862
|
modeItems.value = [];
|
|
852
863
|
found.value = [];
|
|
853
864
|
failed.value = false;
|
|
865
|
+
incomplete.value = false;
|
|
854
866
|
if (o) {
|
|
855
867
|
await nextTick();
|
|
856
868
|
if (props.open) inputRef.value?.focus();
|
|
@@ -917,7 +929,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
917
929
|
_: 1
|
|
918
930
|
}),
|
|
919
931
|
createElementVNode("div", _hoisted_1, [
|
|
920
|
-
!
|
|
932
|
+
!executing.value && !unref(validating) ? (openBlock(), createBlock(_sfc_main$1, {
|
|
921
933
|
key: 0,
|
|
922
934
|
name: "lucide:search",
|
|
923
935
|
size: 18,
|
|
@@ -926,9 +938,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
926
938
|
key: 1,
|
|
927
939
|
size: "inline",
|
|
928
940
|
delay: 0,
|
|
929
|
-
labels: { loading:
|
|
941
|
+
labels: { loading: unref(text).running },
|
|
930
942
|
class: "shrink-0 text-[18px] text-muted"
|
|
931
943
|
}, null, 8, ["labels"])),
|
|
944
|
+
unref(searching) ? (openBlock(), createBlock(_sfc_main$2, {
|
|
945
|
+
key: 2,
|
|
946
|
+
variant: "bar",
|
|
947
|
+
size: "sm",
|
|
948
|
+
tone: "brand",
|
|
949
|
+
labels: { loading: unref(text).searching },
|
|
950
|
+
class: "pointer-events-none absolute inset-x-0 -bottom-px"
|
|
951
|
+
}, null, 8, ["labels"])) : createCommentVNode("", true),
|
|
932
952
|
(openBlock(true), createElementBlock(Fragment, null, renderList(trail.value, (step, i) => {
|
|
933
953
|
return openBlock(), createElementBlock("button", {
|
|
934
954
|
key: step.id,
|
|
@@ -1071,6 +1091,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1071
1091
|
type: linkOf(c) ? void 0 : "button",
|
|
1072
1092
|
role: "option",
|
|
1073
1093
|
tabindex: "-1",
|
|
1094
|
+
"aria-label": c.description ? `${c.label} ${c.description}` : c.label,
|
|
1074
1095
|
"aria-selected": flatIndexOf(c) === selectedIndex.value,
|
|
1075
1096
|
"aria-disabled": c.disabled || void 0,
|
|
1076
1097
|
"data-cmd-index": flatIndexOf(c),
|
|
@@ -1149,7 +1170,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1149
1170
|
})) : createCommentVNode("", true)
|
|
1150
1171
|
]),
|
|
1151
1172
|
_: 2
|
|
1152
|
-
}, 1064, ["id", "href", "type", "aria-selected", "aria-disabled", "data-cmd-index", "disabled", "class", "onClick", "onPointerenter"]);
|
|
1173
|
+
}, 1064, ["id", "href", "type", "aria-label", "aria-selected", "aria-disabled", "data-cmd-index", "disabled", "class", "onClick", "onPointerenter"]);
|
|
1153
1174
|
}), 128))
|
|
1154
1175
|
], 8, _hoisted_12);
|
|
1155
1176
|
}), 128))
|
|
@@ -1164,7 +1185,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1164
1185
|
size: 15,
|
|
1165
1186
|
class: "shrink-0"
|
|
1166
1187
|
}),
|
|
1167
|
-
createElementVNode("span", null, toDisplayString(unref(text).searchFailed), 1),
|
|
1188
|
+
createElementVNode("span", null, toDisplayString(incomplete.value ? unref(text).searchIncomplete : unref(text).searchFailed), 1),
|
|
1168
1189
|
createElementVNode("button", {
|
|
1169
1190
|
type: "button",
|
|
1170
1191
|
class: "rounded-inner px-1.5 py-0.5 font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring",
|
|
@@ -1343,7 +1364,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1343
1364
|
};
|
|
1344
1365
|
}
|
|
1345
1366
|
});
|
|
1346
|
-
const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
1367
|
+
const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-b72dc3e1"]]);
|
|
1347
1368
|
export {
|
|
1348
1369
|
LpCommandPalette as default
|
|
1349
1370
|
};
|
|
@@ -202,3 +202,17 @@ export declare function modeChipLabel(command: {
|
|
|
202
202
|
label: string;
|
|
203
203
|
modeLabel?: string;
|
|
204
204
|
}): string;
|
|
205
|
+
/** What a fetcher may hand back: the rows, a promise of them, or batches of them. */
|
|
206
|
+
export type FetchedRows<T> = T[] | Promise<T[]> | AsyncIterable<T[]>;
|
|
207
|
+
/**
|
|
208
|
+
* Settles a fetcher's answer into its final rows. A plain list or a promise is
|
|
209
|
+
* passed through; batches are walked one by one, each handed to `onBatch` as
|
|
210
|
+
* it lands (each is the whole list so far, replacing the one before), and the
|
|
211
|
+
* last is the result — no batches at all is an empty list.
|
|
212
|
+
*
|
|
213
|
+
* An abort stops the walk at once rather than at the next batch: the iterator
|
|
214
|
+
* is closed (`return()`; a generator parked in an `await` closes at its next
|
|
215
|
+
* `yield`, running its `finally` there) and the wait for the pending batch is
|
|
216
|
+
* dropped. A batch that lands afterwards is not handed on.
|
|
217
|
+
*/
|
|
218
|
+
export declare function settleFetched<T>(result: FetchedRows<T>, signal: AbortSignal, onBatch: (rows: T[]) => void): Promise<T[]>;
|
|
@@ -138,6 +138,47 @@ function modeChipLabel(command) {
|
|
|
138
138
|
if (!command.label.includes("{query}")) return command.label;
|
|
139
139
|
return command.label.replace(/\s*["'“”«»]?\{query\}["'“”«»]?/g, "").replace(/\s+(for|in|on|:)$/i, "").trim();
|
|
140
140
|
}
|
|
141
|
+
function isAsyncIterable(value) {
|
|
142
|
+
return value != null && typeof value[Symbol.asyncIterator] === "function";
|
|
143
|
+
}
|
|
144
|
+
async function settleFetched(result, signal, onBatch) {
|
|
145
|
+
if (!isAsyncIterable(result)) return result;
|
|
146
|
+
const iterator = result[Symbol.asyncIterator]();
|
|
147
|
+
let closed = false;
|
|
148
|
+
const close = () => {
|
|
149
|
+
if (closed) return;
|
|
150
|
+
closed = true;
|
|
151
|
+
void Promise.resolve(iterator.return?.()).catch(() => {
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
let stop;
|
|
155
|
+
const aborted = new Promise((resolve) => {
|
|
156
|
+
stop = () => resolve({ done: true, value: void 0 });
|
|
157
|
+
if (signal.aborted) stop();
|
|
158
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
159
|
+
});
|
|
160
|
+
let rows = [];
|
|
161
|
+
try {
|
|
162
|
+
for (; ; ) {
|
|
163
|
+
const step = await Promise.race([iterator.next(), aborted]);
|
|
164
|
+
if (signal.aborted) {
|
|
165
|
+
close();
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
if (step.done) {
|
|
169
|
+
closed = true;
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
172
|
+
rows = step.value;
|
|
173
|
+
onBatch(rows);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
closed = true;
|
|
177
|
+
throw error;
|
|
178
|
+
} finally {
|
|
179
|
+
if (stop) signal.removeEventListener("abort", stop);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
141
182
|
export {
|
|
142
183
|
PALETTE_MODES,
|
|
143
184
|
absoluteHref,
|
|
@@ -154,5 +195,6 @@ export {
|
|
|
154
195
|
recentKey,
|
|
155
196
|
recentVisible,
|
|
156
197
|
resolveRecentItems,
|
|
198
|
+
settleFetched,
|
|
157
199
|
toRecentItem
|
|
158
200
|
};
|
|
@@ -26,6 +26,11 @@ function formatterFor(locale, style) {
|
|
|
26
26
|
}
|
|
27
27
|
return f;
|
|
28
28
|
}
|
|
29
|
+
const SIGNED = /^[-+\u2212]/;
|
|
30
|
+
function say(fmt, locale, style, value, unit) {
|
|
31
|
+
const text = fmt.format(value, unit);
|
|
32
|
+
return style === "narrow" && SIGNED.test(text) ? formatterFor(locale, "short").format(value, unit) : text;
|
|
33
|
+
}
|
|
29
34
|
const MAX_REFRESH = HOUR;
|
|
30
35
|
const MIN_REFRESH = SECOND;
|
|
31
36
|
function formatRelativeTime(date, now = Date.now(), options = {}) {
|
|
@@ -47,7 +52,7 @@ function formatRelativeTime(date, now = Date.now(), options = {}) {
|
|
|
47
52
|
const boundary = diff < 0 ? (Math.floor(units + 0.5) + 0.5) * step.ms : (Math.ceil(units - 0.5) - 0.5) * step.ms;
|
|
48
53
|
const unitChange = diff < 0 ? step.until - abs : index > 0 ? abs - SCALE[index - 1].until : Infinity;
|
|
49
54
|
const refreshIn = Math.min(diff < 0 ? boundary - abs : abs - boundary, unitChange);
|
|
50
|
-
return { text: fmt
|
|
55
|
+
return { text: say(fmt, locale, style, value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) };
|
|
51
56
|
}
|
|
52
57
|
function clampRefresh(ms) {
|
|
53
58
|
return clamp(Math.ceil(ms), MIN_REFRESH, MAX_REFRESH);
|
|
@@ -205,7 +205,7 @@ function install() {
|
|
|
205
205
|
window.addEventListener("popstate", afterPop);
|
|
206
206
|
const push = history.pushState.bind(history);
|
|
207
207
|
const replace = history.replaceState.bind(history);
|
|
208
|
-
|
|
208
|
+
const pushed = (data, unused, url) => {
|
|
209
209
|
const before = location.href;
|
|
210
210
|
push(data, unused, url);
|
|
211
211
|
s.href = location.href;
|
|
@@ -214,12 +214,26 @@ function install() {
|
|
|
214
214
|
if (pathOf(before) !== pathOf(location.href)) leavePage();
|
|
215
215
|
mute();
|
|
216
216
|
};
|
|
217
|
-
|
|
217
|
+
const replaced = (data, unused, url) => {
|
|
218
218
|
const mark = s.internal ? null : current();
|
|
219
219
|
const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...data, [KEY]: mark } : data;
|
|
220
220
|
replace(keep, unused, url);
|
|
221
221
|
s.href = location.href;
|
|
222
222
|
};
|
|
223
|
+
history.pushState = function(data, unused, url) {
|
|
224
|
+
if (!s.internal && s.silent > 0) s.waiting.push(() => pushed(unmarked(data), unused, url));
|
|
225
|
+
else pushed(data, unused, url);
|
|
226
|
+
};
|
|
227
|
+
history.replaceState = function(data, unused, url) {
|
|
228
|
+
if (!s.internal && s.silent > 0) s.waiting.push(() => replaced(unmarked(data), unused, url));
|
|
229
|
+
else replaced(data, unused, url);
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function unmarked(data) {
|
|
233
|
+
if (!markOf(data)) return data;
|
|
234
|
+
const copy = { ...data };
|
|
235
|
+
delete copy[KEY];
|
|
236
|
+
return copy;
|
|
223
237
|
}
|
|
224
238
|
function pushEntry(entry) {
|
|
225
239
|
if (typeof window === "undefined") return;
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* and an error from a superseded run is dropped unseen. It is also one slot:
|
|
13
13
|
* a new `run` replaces the last, and `pending` speaks for the latest only, so
|
|
14
14
|
* independent requests each need their own `useAsyncTask`.
|
|
15
|
+
*
|
|
16
|
+
* A task that has something to show before it is done (the command palette's
|
|
17
|
+
* streamed results) calls `emit` with it: that reaches `apply` at once, for
|
|
18
|
+
* the latest run only, while `pending` stays true until the task settles. The
|
|
19
|
+
* value the task finally resolves to is applied as well, after the last emit.
|
|
15
20
|
*/
|
|
16
21
|
export declare function useAsyncTask<T>(): {
|
|
17
22
|
pending: import("vue").Ref<boolean, boolean>;
|
|
18
|
-
run: (task: (signal: AbortSignal) => T | Promise<T>, options: {
|
|
23
|
+
run: (task: (signal: AbortSignal, emit: (value: T) => void) => T | Promise<T>, options: {
|
|
19
24
|
delay?: number;
|
|
20
25
|
apply: (value: T) => void;
|
|
21
26
|
fail?: (error: unknown) => void;
|
|
@@ -19,7 +19,10 @@ function useAsyncTask() {
|
|
|
19
19
|
timer = setTimeout(async () => {
|
|
20
20
|
controller = new AbortController();
|
|
21
21
|
try {
|
|
22
|
-
const
|
|
22
|
+
const emit = (value2) => {
|
|
23
|
+
if (mine === token) options.apply(value2);
|
|
24
|
+
};
|
|
25
|
+
const value = await task(controller.signal, emit);
|
|
23
26
|
if (mine === token) options.apply(value);
|
|
24
27
|
} catch (error) {
|
|
25
28
|
if (mine === token) options.fail?.(error);
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { _ as _3 } from "./chunks/LpMapPicker-DW2EnrCJ.js";
|
|
|
4
4
|
import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
|
|
5
5
|
import { _ as _4 } from "./chunks/LpActionBar-C7g52Cyo.js";
|
|
6
6
|
import { _ as _5 } from "./chunks/LpAlert-DWFeR82L.js";
|
|
7
|
-
import { _ as _6 } from "./chunks/LpAppShell-
|
|
7
|
+
import { _ as _6 } from "./chunks/LpAppShell-BBINfN1v.js";
|
|
8
8
|
import { _ as _7 } from "./chunks/LpAutocomplete-DY3LvpB9.js";
|
|
9
9
|
import { _ as _8 } from "./chunks/LpAvatar-CEZmMcGh.js";
|
|
10
10
|
import { _ as _9 } from "./chunks/LpBadge-C_VqLHtg.js";
|
|
@@ -83,7 +83,7 @@ import { _ as _54 } from "./chunks/LpRadio-CWA5Eb3M.js";
|
|
|
83
83
|
import { _ as _55 } from "./chunks/LpRadioGroup-E3SP6j9h.js";
|
|
84
84
|
import { _ as _56 } from "./chunks/LpRollingText-B54Pje7P.js";
|
|
85
85
|
import { _ as _57 } from "./chunks/LpScrollArea-CqvwfZs2.js";
|
|
86
|
-
import { _ as _58 } from "./chunks/LpPageScrollbar-
|
|
86
|
+
import { _ as _58 } from "./chunks/LpPageScrollbar-C9sTeU-2.js";
|
|
87
87
|
import { _ as _59 } from "./chunks/LpSegmented-DjvrHL_G.js";
|
|
88
88
|
import { _ as _60 } from "./chunks/LpSection-CghNNxIv.js";
|
|
89
89
|
import { _ as _61 } from "./chunks/LpSelect-DYPimzAZ.js";
|
package/package.json
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
toRecentItem,
|
|
88
88
|
type RecentEntry,
|
|
89
89
|
type RecentItem,
|
|
90
|
+
settleFetched,
|
|
90
91
|
} from "./commandPalette"
|
|
91
92
|
import { useOutsideGuard, usePortalTarget, useView, viewWindow } from "../composables/useView"
|
|
92
93
|
|
|
@@ -279,12 +280,31 @@ export interface CommandPrompt {
|
|
|
279
280
|
* after the palette closed, is discarded and never reaches the list, so a
|
|
280
281
|
* fetcher does not need to check the signal itself. Checking it before an
|
|
281
282
|
* expensive step is only a saving.
|
|
283
|
+
*
|
|
284
|
+
* A fetcher that gathers from sources of different speeds (a local index, a
|
|
285
|
+
* database, a slow remote) can return an async iterable instead — an async
|
|
286
|
+
* generator is the easy way — and yield as it goes, so the quick sources show
|
|
287
|
+
* without waiting for the slow one. Each batch is the whole list so far and
|
|
288
|
+
* replaces the one before; the last is the answer. The search keeps its
|
|
289
|
+
* loading bar until the iterable ends, the highlight stays on the row it was
|
|
290
|
+
* on as rows are added, and when the query moves on the signal aborts and the
|
|
291
|
+
* iterable is closed (`return()`) — a generator parked in an `await` only
|
|
292
|
+
* closes at its next `yield`, running its `finally` there, so pass the signal
|
|
293
|
+
* on to what it awaits. A throw after some batches keeps them on screen, with
|
|
294
|
+
* a line saying the rest could not be fetched:
|
|
295
|
+
*
|
|
296
|
+
* async function* search(q, signal) {
|
|
297
|
+
* const local = index.find(q)
|
|
298
|
+
* yield local
|
|
299
|
+
* const remote = await api.search(q, { signal })
|
|
300
|
+
* yield [...local, ...remote]
|
|
301
|
+
* }
|
|
282
302
|
*/
|
|
283
303
|
export type CommandFetcher = (
|
|
284
304
|
query: string,
|
|
285
305
|
signal: AbortSignal,
|
|
286
306
|
parsed: PaletteQuery,
|
|
287
|
-
) => Command[] | Promise<Command[]>
|
|
307
|
+
) => Command[] | Promise<Command[]> | AsyncIterable<Command[]>
|
|
288
308
|
|
|
289
309
|
/** Footer hints and the mode chip, spelled out so an app can translate them. */
|
|
290
310
|
export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
@@ -305,6 +325,8 @@ export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
|
305
325
|
searching: string
|
|
306
326
|
/** Shown when `search` (or a fetching mode) threw. */
|
|
307
327
|
searchFailed: string
|
|
328
|
+
/** Shown under streamed results when the fetcher threw after some of them; defaults to "Some results could not be loaded." */
|
|
329
|
+
searchIncomplete?: string
|
|
308
330
|
retry: string
|
|
309
331
|
/** The row that unfolds a group cut by `groupLimit`; `{count}` is how many are hidden. */
|
|
310
332
|
showMore: string
|
|
@@ -508,6 +530,7 @@ const DEFAULT_LABELS: Required<PaletteLabels> = {
|
|
|
508
530
|
back: "back",
|
|
509
531
|
searching: "Searching…",
|
|
510
532
|
searchFailed: "Search is unavailable right now.",
|
|
533
|
+
searchIncomplete: "Some results could not be loaded.",
|
|
511
534
|
retry: "Retry",
|
|
512
535
|
showMore: "Show {count} more",
|
|
513
536
|
actions: "actions",
|
|
@@ -762,11 +785,14 @@ function cancel() {
|
|
|
762
785
|
* drops a superseded request before `fail` runs.
|
|
763
786
|
*/
|
|
764
787
|
const failed = ref(false)
|
|
788
|
+
/** The failure came after some batches had shown: those stay, the line says the rest is missing. */
|
|
789
|
+
const incomplete = ref(false)
|
|
765
790
|
|
|
766
791
|
function refresh(keepRows = false) {
|
|
767
792
|
cancel()
|
|
768
793
|
if (!keepRows) found.value = []
|
|
769
794
|
failed.value = false
|
|
795
|
+
incomplete.value = false
|
|
770
796
|
const run = fetcher()
|
|
771
797
|
if (!run) return
|
|
772
798
|
const parsed = parsePaletteQuery(query.value.trim())
|
|
@@ -778,14 +804,25 @@ function refresh(keepRows = false) {
|
|
|
778
804
|
if (!inMode && q.length < Math.max(1, props.minChars)) return
|
|
779
805
|
// A failing provider: the static commands still stand, with a line saying
|
|
780
806
|
// that the rest could not be fetched.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
807
|
+
// Streamed batches go through `emit`, which drops them once this request
|
|
808
|
+
// is superseded; `streamed` tells a failure after them from one before.
|
|
809
|
+
let streamed = false
|
|
810
|
+
task.run(
|
|
811
|
+
(signal, emit) =>
|
|
812
|
+
settleFetched(run(q, signal, parsed), signal, (rows) => {
|
|
813
|
+
streamed = true
|
|
814
|
+
emit(rows)
|
|
815
|
+
}),
|
|
816
|
+
{
|
|
817
|
+
delay: props.debounce,
|
|
818
|
+
apply: (hits) => (found.value = hits),
|
|
819
|
+
fail: () => {
|
|
820
|
+
if (!streamed) found.value = []
|
|
821
|
+
incomplete.value = streamed
|
|
822
|
+
failed.value = true
|
|
823
|
+
},
|
|
787
824
|
},
|
|
788
|
-
|
|
825
|
+
)
|
|
789
826
|
}
|
|
790
827
|
|
|
791
828
|
// ── filtering + scoring ──────────────────────────────────────
|
|
@@ -1483,6 +1520,7 @@ watch(
|
|
|
1483
1520
|
modeItems.value = []
|
|
1484
1521
|
found.value = []
|
|
1485
1522
|
failed.value = false
|
|
1523
|
+
incomplete.value = false
|
|
1486
1524
|
if (o) {
|
|
1487
1525
|
await nextTick()
|
|
1488
1526
|
// Closed again meanwhile: the box belongs to a dialog on its way out.
|
|
@@ -1547,22 +1585,34 @@ const guardOutside = useOutsideGuard()
|
|
|
1547
1585
|
<!-- Filter. The trail sits inside the box as chips, the way a scoped
|
|
1548
1586
|
search reads everywhere else: what you type is filtered by what is
|
|
1549
1587
|
to its left. -->
|
|
1550
|
-
<div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
1588
|
+
<div class="relative flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
1551
1589
|
<LpIcon
|
|
1552
|
-
v-if="!
|
|
1590
|
+
v-if="!executing && !validating"
|
|
1553
1591
|
name="lucide:search"
|
|
1554
1592
|
:size="18"
|
|
1555
1593
|
class="shrink-0 text-muted"
|
|
1556
1594
|
/>
|
|
1557
|
-
<!-- At once, since it takes the search icon's place.
|
|
1558
|
-
thing that tells a screen reader a search went out, so it speaks. -->
|
|
1595
|
+
<!-- At once, since it takes the search icon's place. -->
|
|
1559
1596
|
<LpLoader
|
|
1560
1597
|
v-else
|
|
1561
1598
|
size="inline"
|
|
1562
1599
|
:delay="0"
|
|
1563
|
-
:labels="{ loading:
|
|
1600
|
+
:labels="{ loading: text.running }"
|
|
1564
1601
|
class="shrink-0 text-[18px] text-muted"
|
|
1565
1602
|
/>
|
|
1603
|
+
<!-- A search under way runs a bar along the box's lower edge, over
|
|
1604
|
+
the border, for as long as it lasts: results that stream in
|
|
1605
|
+
batches are already on screen by then, and the bar is what says
|
|
1606
|
+
more may come. It is also what tells a screen reader a search
|
|
1607
|
+
went out, so it speaks. -->
|
|
1608
|
+
<LpLoader
|
|
1609
|
+
v-if="searching"
|
|
1610
|
+
variant="bar"
|
|
1611
|
+
size="sm"
|
|
1612
|
+
tone="brand"
|
|
1613
|
+
:labels="{ loading: text.searching }"
|
|
1614
|
+
class="pointer-events-none absolute inset-x-0 -bottom-px"
|
|
1615
|
+
/>
|
|
1566
1616
|
|
|
1567
1617
|
<button
|
|
1568
1618
|
v-for="(step, i) in trail"
|
|
@@ -1646,6 +1696,7 @@ const guardOutside = useOutsideGuard()
|
|
|
1646
1696
|
:type="linkOf(c) ? undefined : 'button'"
|
|
1647
1697
|
role="option"
|
|
1648
1698
|
tabindex="-1"
|
|
1699
|
+
:aria-label="c.description ? `${c.label} ${c.description}` : c.label"
|
|
1649
1700
|
:aria-selected="flatIndexOf(c) === selectedIndex"
|
|
1650
1701
|
:aria-disabled="c.disabled || undefined"
|
|
1651
1702
|
:data-cmd-index="flatIndexOf(c)"
|
|
@@ -1663,6 +1714,10 @@ const guardOutside = useOutsideGuard()
|
|
|
1663
1714
|
@pointerenter="onPointerEnter(c)"
|
|
1664
1715
|
@pointerleave="onPointerLeave"
|
|
1665
1716
|
>
|
|
1717
|
+
<!-- Named from label and description, not from content: split
|
|
1718
|
+
into <mark> parts, a row's text was not always read as its
|
|
1719
|
+
name (BotEco's palette showed nameless options), and the
|
|
1720
|
+
shortcut or prefix kbd is not part of what the row is. -->
|
|
1666
1721
|
<!-- A picture takes the icon's slot at the icon's footprint, with
|
|
1667
1722
|
a negative margin so a 20px avatar does not make its row
|
|
1668
1723
|
taller than its neighbours. -->
|
|
@@ -1733,7 +1788,7 @@ const guardOutside = useOutsideGuard()
|
|
|
1733
1788
|
:class="flat.length ? 'pb-1 pt-3' : 'py-10'"
|
|
1734
1789
|
>
|
|
1735
1790
|
<LpIcon name="lucide:circle-alert" :size="15" class="shrink-0" />
|
|
1736
|
-
<span>{{ text.searchFailed }}</span>
|
|
1791
|
+
<span>{{ incomplete ? text.searchIncomplete : text.searchFailed }}</span>
|
|
1737
1792
|
<button
|
|
1738
1793
|
type="button"
|
|
1739
1794
|
class="rounded-inner px-1.5 py-0.5 font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* (the bar is not printed, and at rest it is not drawn). What the kit adds is
|
|
14
14
|
* the drawing, a thumb that drags, and a press beside it that pages.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* With a mouse or touchpad it draws the bar, also where the browser's bars
|
|
17
|
+
* float over the content (macOS, GTK on Linux, Windows 11's overlay setting).
|
|
18
|
+
* With a finger as the main pointer (phones, tablets) it draws nothing and
|
|
19
|
+
* leaves the browser's.
|
|
18
20
|
*
|
|
19
21
|
* `top` and `bottom` keep the bar clear of what covers the window's edges: a
|
|
20
22
|
* sticky header, a bottom bar. Any CSS length, a variable included, and a
|
|
@@ -51,14 +53,14 @@ function scroller(): HTMLElement | null {
|
|
|
51
53
|
return (doc?.scrollingElement as HTMLElement | null) ?? null
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
/*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
56
|
+
/*
|
|
57
|
+
* Decided by the input, not by the browser's bar: desktop bars float over the
|
|
58
|
+
* content too (GTK's overlay bars on Linux, Windows 11's setting), and there the
|
|
59
|
+
* kit's bar is wanted beside the rest of the kit. A finger has no use for a thumb
|
|
60
|
+
* to drag, so a touch screen keeps the browser's.
|
|
61
|
+
*/
|
|
62
|
+
function finePointer(d: Document): boolean {
|
|
63
|
+
return nodeWindow(d).matchMedia?.("(pointer: fine)").matches ?? false
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function measure() {
|
|
@@ -138,7 +140,7 @@ function onThumbPointerDown(event: PointerEvent) {
|
|
|
138
140
|
onMounted(() => {
|
|
139
141
|
const host = barEl.value ?? undefined
|
|
140
142
|
doc = host?.ownerDocument
|
|
141
|
-
if (!doc ||
|
|
143
|
+
if (!doc || !finePointer(doc)) return
|
|
142
144
|
active.value = true
|
|
143
145
|
releaseNative = hideNativeBar(doc)
|
|
144
146
|
const win = nodeWindow(doc)
|
|
@@ -344,3 +344,65 @@ export function modeChipLabel(command: { label: string; modeLabel?: string }): s
|
|
|
344
344
|
.replace(/\s+(for|in|on|:)$/i, "")
|
|
345
345
|
.trim()
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
/** What a fetcher may hand back: the rows, a promise of them, or batches of them. */
|
|
349
|
+
export type FetchedRows<T> = T[] | Promise<T[]> | AsyncIterable<T[]>
|
|
350
|
+
|
|
351
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
352
|
+
return value != null && typeof (value as AsyncIterable<T>)[Symbol.asyncIterator] === "function"
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Settles a fetcher's answer into its final rows. A plain list or a promise is
|
|
357
|
+
* passed through; batches are walked one by one, each handed to `onBatch` as
|
|
358
|
+
* it lands (each is the whole list so far, replacing the one before), and the
|
|
359
|
+
* last is the result — no batches at all is an empty list.
|
|
360
|
+
*
|
|
361
|
+
* An abort stops the walk at once rather than at the next batch: the iterator
|
|
362
|
+
* is closed (`return()`; a generator parked in an `await` closes at its next
|
|
363
|
+
* `yield`, running its `finally` there) and the wait for the pending batch is
|
|
364
|
+
* dropped. A batch that lands afterwards is not handed on.
|
|
365
|
+
*/
|
|
366
|
+
export async function settleFetched<T>(
|
|
367
|
+
result: FetchedRows<T>,
|
|
368
|
+
signal: AbortSignal,
|
|
369
|
+
onBatch: (rows: T[]) => void,
|
|
370
|
+
): Promise<T[]> {
|
|
371
|
+
if (!isAsyncIterable<T[]>(result)) return result
|
|
372
|
+
const iterator = result[Symbol.asyncIterator]()
|
|
373
|
+
let closed = false
|
|
374
|
+
const close = () => {
|
|
375
|
+
if (closed) return
|
|
376
|
+
closed = true
|
|
377
|
+
// A generator still awaiting inside rejects its queued return with the
|
|
378
|
+
// error it later throws; that is not ours to report.
|
|
379
|
+
void Promise.resolve(iterator.return?.()).catch(() => {})
|
|
380
|
+
}
|
|
381
|
+
let stop: (() => void) | undefined
|
|
382
|
+
const aborted = new Promise<IteratorResult<T[]>>((resolve) => {
|
|
383
|
+
stop = () => resolve({ done: true, value: undefined })
|
|
384
|
+
if (signal.aborted) stop()
|
|
385
|
+
else signal.addEventListener("abort", stop, { once: true })
|
|
386
|
+
})
|
|
387
|
+
let rows: T[] = []
|
|
388
|
+
try {
|
|
389
|
+
for (;;) {
|
|
390
|
+
const step = await Promise.race([iterator.next(), aborted])
|
|
391
|
+
if (signal.aborted) {
|
|
392
|
+
close()
|
|
393
|
+
return rows
|
|
394
|
+
}
|
|
395
|
+
if (step.done) {
|
|
396
|
+
closed = true
|
|
397
|
+
return rows
|
|
398
|
+
}
|
|
399
|
+
rows = step.value
|
|
400
|
+
onBatch(rows)
|
|
401
|
+
}
|
|
402
|
+
} catch (error) {
|
|
403
|
+
closed = true
|
|
404
|
+
throw error
|
|
405
|
+
} finally {
|
|
406
|
+
if (stop) signal.removeEventListener("abort", stop)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -68,6 +68,18 @@ function formatterFor(locale: string | undefined, style: Intl.RelativeTimeFormat
|
|
|
68
68
|
return f
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/*
|
|
72
|
+
* Some locales' narrow form drops the words for past and future and keeps a
|
|
73
|
+
* bare sign: ru narrow is "-4 мин" and "+3 ч", where short says "4 мин. назад"
|
|
74
|
+
* and "через 3 ч". A sign reads as arithmetic, not as "ago", so such a text
|
|
75
|
+
* is said in the short form instead.
|
|
76
|
+
*/
|
|
77
|
+
const SIGNED = /^[-+\u2212]/
|
|
78
|
+
function say(fmt: Intl.RelativeTimeFormat, locale: string | undefined, style: Intl.RelativeTimeFormatStyle, value: number, unit: Intl.RelativeTimeFormatUnit): string {
|
|
79
|
+
const text = fmt.format(value, unit)
|
|
80
|
+
return style === "narrow" && SIGNED.test(text) ? formatterFor(locale, "short").format(value, unit) : text
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
/** Never sleep longer than this: a laptop waking from sleep should not show "2 h ago" for an hour. */
|
|
72
84
|
const MAX_REFRESH = HOUR
|
|
73
85
|
const MIN_REFRESH = SECOND
|
|
@@ -111,7 +123,7 @@ export function formatRelativeTime(
|
|
|
111
123
|
const unitChange = diff < 0 ? step.until - abs : index > 0 ? abs - SCALE[index - 1].until : Infinity
|
|
112
124
|
const refreshIn = Math.min(diff < 0 ? boundary - abs : abs - boundary, unitChange)
|
|
113
125
|
|
|
114
|
-
return { text: fmt
|
|
126
|
+
return { text: say(fmt, locale, style, value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) }
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
function clampRefresh(ms: number): number {
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
* entries keep working once Back returns past it.
|
|
32
32
|
* - Someone else's `replaceState` on a kit entry keeps the kit's key, so a
|
|
33
33
|
* router saving its scroll position does not orphan the entry.
|
|
34
|
+
* - Someone else's `pushState` or `replaceState` while a step the kit started
|
|
35
|
+
* is on its way (closing an overlay and following a link in it in one tick)
|
|
36
|
+
* is held until the step lands, then applied there: the address changes a
|
|
37
|
+
* traversal later than the router's own route does.
|
|
34
38
|
* - An app that keeps an overlay in its query through `replace` rewrites the
|
|
35
39
|
* address of the kit's entry when the overlay closes, and the entry under it
|
|
36
40
|
* still holds the old one. Taking the entry out lands there, so the address
|
|
@@ -362,7 +366,7 @@ function install() {
|
|
|
362
366
|
|
|
363
367
|
const push = history.pushState.bind(history)
|
|
364
368
|
const replace = history.replaceState.bind(history)
|
|
365
|
-
|
|
369
|
+
const pushed = (data: unknown, unused: string, url?: string | URL | null) => {
|
|
366
370
|
const before = location.href
|
|
367
371
|
push(data, unused, url)
|
|
368
372
|
s.href = location.href
|
|
@@ -371,12 +375,35 @@ function install() {
|
|
|
371
375
|
if (pathOf(before) !== pathOf(location.href)) leavePage()
|
|
372
376
|
mute()
|
|
373
377
|
}
|
|
374
|
-
|
|
378
|
+
const replaced = (data: unknown, unused: string, url?: string | URL | null) => {
|
|
375
379
|
const mark = s.internal ? null : current()
|
|
376
380
|
const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...(data as object), [KEY]: mark } : data
|
|
377
381
|
replace(keep, unused, url)
|
|
378
382
|
s.href = location.href
|
|
379
383
|
}
|
|
384
|
+
history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
|
|
385
|
+
if (!s.internal && s.silent > 0) s.waiting.push(() => pushed(unmarked(data), unused, url))
|
|
386
|
+
else pushed(data, unused, url)
|
|
387
|
+
}
|
|
388
|
+
history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
|
|
389
|
+
if (!s.internal && s.silent > 0) s.waiting.push(() => replaced(unmarked(data), unused, url))
|
|
390
|
+
else replaced(data, unused, url)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* A change written while a step the kit started is on its way (an overlay
|
|
396
|
+
* closed and a link inside it navigated in the same tick) waits for the step to
|
|
397
|
+
* land. Applied at once, the step would then go back from the new entry, and
|
|
398
|
+
* the page would end on the old path. A router builds its state from the entry
|
|
399
|
+
* it sat on, the kit's, so the kit's key is taken off: the entry it lands on is
|
|
400
|
+
* not the kit's any more.
|
|
401
|
+
*/
|
|
402
|
+
function unmarked(data: unknown): unknown {
|
|
403
|
+
if (!markOf(data)) return data
|
|
404
|
+
const copy = { ...(data as Record<string, unknown>) }
|
|
405
|
+
delete copy[KEY]
|
|
406
|
+
return copy
|
|
380
407
|
}
|
|
381
408
|
|
|
382
409
|
/** Record an entry on top of the history, under the current address. */
|
|
@@ -14,6 +14,11 @@ import { getCurrentScope, onScopeDispose, ref } from "vue"
|
|
|
14
14
|
* and an error from a superseded run is dropped unseen. It is also one slot:
|
|
15
15
|
* a new `run` replaces the last, and `pending` speaks for the latest only, so
|
|
16
16
|
* independent requests each need their own `useAsyncTask`.
|
|
17
|
+
*
|
|
18
|
+
* A task that has something to show before it is done (the command palette's
|
|
19
|
+
* streamed results) calls `emit` with it: that reaches `apply` at once, for
|
|
20
|
+
* the latest run only, while `pending` stays true until the task settles. The
|
|
21
|
+
* value the task finally resolves to is applied as well, after the last emit.
|
|
17
22
|
*/
|
|
18
23
|
export function useAsyncTask<T>() {
|
|
19
24
|
const pending = ref(false)
|
|
@@ -31,7 +36,7 @@ export function useAsyncTask<T>() {
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function run(
|
|
34
|
-
task: (signal: AbortSignal) => T | Promise<T>,
|
|
39
|
+
task: (signal: AbortSignal, emit: (value: T) => void) => T | Promise<T>,
|
|
35
40
|
options: { delay?: number; apply: (value: T) => void; fail?: (error: unknown) => void },
|
|
36
41
|
) {
|
|
37
42
|
cancel()
|
|
@@ -40,7 +45,10 @@ export function useAsyncTask<T>() {
|
|
|
40
45
|
timer = setTimeout(async () => {
|
|
41
46
|
controller = new AbortController()
|
|
42
47
|
try {
|
|
43
|
-
const
|
|
48
|
+
const emit = (value: T) => {
|
|
49
|
+
if (mine === token) options.apply(value)
|
|
50
|
+
}
|
|
51
|
+
const value = await task(controller.signal, emit)
|
|
44
52
|
if (mine === token) options.apply(value)
|
|
45
53
|
} catch (error) {
|
|
46
54
|
if (mine === token) options.fail?.(error)
|