@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.
@@ -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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689],
44
- .lp-cmd-pane-leave-active[data-v-04518689] {
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-04518689],
50
- .lp-cmd-pane-leave-to[data-v-04518689] {
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-04518689] {
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-04518689],
60
- .lp-cmd-pane-enter-active[data-v-04518689],
61
- .lp-cmd-pane-leave-active[data-v-04518689] {
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-04518689] {
64
+ .lp-cmd-para[data-v-b72dc3e1] {
65
65
  animation: none;
66
66
  }
67
67
  }
68
- .lp-cmd-excerpt[data-v-04518689] {
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-04518689] {
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-04518689] {
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-D2O0xhJw.js";
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 nativeBarWidth(d) {
33
- const probe = d.createElement("div");
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 || nativeBarWidth(doc) === 0) return;
105
+ if (!doc || !finePointer(doc)) return;
111
106
  active.value = true;
112
107
  releaseNative = hideNativeBar(doc);
113
108
  const win = nodeWindow(doc);
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-InIK01Nn.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-BBINfN1v.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -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, createCommentVNode, withDirectives, withKeys, vModelText, resolveDynamicComponent, vShow, Transition, renderSlot } from "vue";
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-DDJKICKh.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
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
- task.run((signal) => run2(q, signal, parsed), {
356
- delay: props.debounce,
357
- apply: (hits) => found.value = hits,
358
- fail: () => {
359
- found.value = [];
360
- failed.value = true;
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
- !unref(searching) && !executing.value && !unref(validating) ? (openBlock(), createBlock(_sfc_main$1, {
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: executing.value || unref(validating) ? unref(text).running : unref(text).searching },
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-04518689"]]);
1367
+ const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-b72dc3e1"]]);
1347
1368
  export {
1348
1369
  LpCommandPalette as default
1349
1370
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpPageScrollbar-D2O0xhJw.js";
1
+ import { _ as _sfc_main } from "../chunks/LpPageScrollbar-C9sTeU-2.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -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.format(value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) };
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
- history.pushState = function(data, unused, url) {
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
- history.replaceState = function(data, unused, url) {
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 value = await task(controller.signal);
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-InIK01Nn.js";
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-D2O0xhJw.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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
- task.run((signal) => run(q, signal, parsed), {
782
- delay: props.debounce,
783
- apply: (hits) => (found.value = hits),
784
- fail: () => {
785
- found.value = []
786
- failed.value = true
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="!searching && !executing && !validating"
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. It is the only
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: executing || validating ? text.running : text.searching }"
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
- * Where the browser's bars already float over the content (phones, macOS set
17
- * to show them while scrolling) it draws nothing and leaves the browser's.
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
- /* The width the browser's bar takes from the layout: 0 where it floats over the content. */
55
- function nativeBarWidth(d: Document): number {
56
- const probe = d.createElement("div")
57
- probe.style.cssText = "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll"
58
- d.body.append(probe)
59
- const width = probe.offsetWidth - probe.clientWidth
60
- probe.remove()
61
- return width
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 || nativeBarWidth(doc) === 0) return
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.format(value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) }
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
- history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
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
- history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
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 value = await task(controller.signal)
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)