bitboss-ui 3.0.0-beta.64 → 3.0.0-beta.65

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 (36) hide show
  1. package/dist/ai/changelog.json +82 -4
  2. package/dist/ai/components.json +2 -2
  3. package/dist/ai/composables/useBbConfig.md +8 -1
  4. package/dist/ai/guides/inertia-helpers.md +1127 -275
  5. package/dist/ai/guides/migration/v2-to-v3.md +48 -0
  6. package/dist/ai/guides/passthrough.md +4 -2
  7. package/dist/ai/helpers/inertia/manifest.json +145 -62
  8. package/dist/ai/helpers/inertia/messages.ts +33 -0
  9. package/dist/ai/helpers/inertia/useConfirmOnRouteLeave.ts +116 -16
  10. package/dist/ai/helpers/inertia/useHasChanged.ts +34 -16
  11. package/dist/ai/helpers/inertia/useLazyProp.ts +102 -36
  12. package/dist/ai/helpers/inertia/useLazyPropGetter.ts +3 -1
  13. package/dist/ai/helpers/inertia/usePaginatedResponseContext.ts +129 -24
  14. package/dist/ai/helpers/inertia/usePromiseForm.ts +121 -14
  15. package/dist/ai/helpers/inertia/useQuery.ts +133 -11
  16. package/dist/ai/helpers/inertia/useQueryObject.ts +74 -67
  17. package/dist/ai/helpers/inertia/useResourceContext.ts +60 -14
  18. package/dist/ai/helpers/inertia/useRouteQuery.ts +29 -24
  19. package/dist/ai/helpers/inertia/useScreenSizes.ts +9 -7
  20. package/dist/ai/helpers/inertia/useTableContext.ts +5 -2
  21. package/dist/ai/helpers/inertia/utils.ts +14 -0
  22. package/dist/ai/manifest/index.json +2 -2
  23. package/dist/ai/recipes/inertia/approvals-inbox.md +1 -1
  24. package/dist/ai/recipes/inertia/inline-edit-workspace.md +1 -1
  25. package/dist/ai/recipes/inertia/ownership-atlas.md +1 -1
  26. package/dist/ai/recipes/inertia/records-workspace.md +1 -1
  27. package/dist/ai/recipes/inertia/upload-center.md +1 -1
  28. package/dist/ai/source/BbSelectPopover.md +25 -1
  29. package/dist/components/BbSelectPopover/BbSelectPopover.vue_vue_type_script_setup_true_lang.js +306 -294
  30. package/dist/composables/useBbConfig.d.ts +1 -0
  31. package/dist/composables/useBbConfig.js +11 -7
  32. package/dist/llms-full.txt +1192 -283
  33. package/dist/styles.css +1 -1
  34. package/dist/utils/versionCheck.js +1 -1
  35. package/package.json +3 -2
  36. package/scripts/lib/helpers-add.mjs +26 -2
@@ -1,95 +1,98 @@
1
1
  // inertia-helpers/useQueryObject.ts
2
2
  // requires: npm i query-string
3
- import { useQuery } from './useQuery';
4
- import { isEmpty, isNil } from './utils';
5
- import { router, usePage } from '@inertiajs/vue3';
6
- import qs, { type StringifyOptions } from 'query-string';
3
+ import {
4
+ QUERY_ARRAY_FORMAT,
5
+ currentUrl,
6
+ parseQuery,
7
+ visitUrl,
8
+ withQuery,
9
+ } from './useQuery';
10
+ import type { VisitOptions } from '@inertiajs/core';
11
+ import qs from 'query-string';
7
12
  import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from 'vue';
8
13
 
14
+ /** A server visit's options; the URL, method and data are the helper's. */
15
+ type ServerVisitOptions = Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>;
16
+ /** A URL-only write (`server: false`) has no request to configure. */
17
+ type ClientVisitOptions = Pick<
18
+ VisitOptions,
19
+ 'preserveState' | 'preserveScroll' | 'replace'
20
+ >;
21
+
9
22
  type Options<T> = {
10
- transform?: (value: Partial<T>) => Partial<T>;
11
- /** Reset the `page` parameter on change. Default true. */
12
- resetPage?: boolean;
13
- /** Additional query keys removed whenever the object changes. */
23
+ /**
24
+ * Query keys removed on every change. Default `['page']`, so a filter
25
+ * change never strands the list on a page past its new end. Passing it
26
+ * REPLACES the default: name `page` too when it should still reset.
27
+ */
14
28
  resetKeys?: string[];
15
- /** Inertia partial reload prop names (supports reactive getter). */
16
- only?: MaybeRefOrGetter<string[]>;
17
29
  /**
18
- * When it resolves to `false` the watcher keeps tracking keys but skips
19
- * the navigation — use it when another owner performs the visit during a
20
- * programmatic transition, to avoid competing self-cancelling requests.
30
+ * While it resolves to `true`, changes are tracked but no visit is sent.
31
+ * When it goes back to `false`, the object's current state is sent once,
32
+ * if the URL does not already hold it. See the guide for when to use it.
21
33
  */
22
- shouldNavigate?: MaybeRefOrGetter<boolean>;
34
+ paused?: MaybeRefOrGetter<boolean>;
23
35
  /**
24
36
  * Keys whose changes wait this many ms before the visit, e.g. a search
25
37
  * box: `{ search: 300 }`. A change to any other key visits at once and
26
38
  * carries the waiting value with it, so nothing typed is lost.
27
39
  */
28
40
  debounce?: Partial<Record<keyof T & string, number>>;
29
- arrayFormat?: StringifyOptions['arrayFormat'];
30
- preserveState?: boolean;
31
- preserveScroll?: boolean;
32
- replace?: boolean;
33
- };
41
+ } & (
42
+ | {
43
+ /** Default `true`: every change is an Inertia visit. */
44
+ server?: true;
45
+ /** Merged over `{ preserveState, preserveScroll, replace: true }`. */
46
+ visitOptions?: MaybeRefOrGetter<ServerVisitOptions>;
47
+ }
48
+ | {
49
+ /** `false` writes the URL with no request (`router.replace` / `push`). */
50
+ server: false;
51
+ visitOptions?: MaybeRefOrGetter<ClientVisitOptions>;
52
+ }
53
+ );
34
54
 
35
55
  export function useQueryObject<T extends Record<string, unknown>>(
36
56
  object: MaybeRefOrGetter<T>,
37
57
  options: Options<T> = {}
38
58
  ): void {
39
59
  const {
40
- transform,
41
- resetPage = true,
42
- resetKeys = [],
43
- preserveState = true,
44
- preserveScroll = true,
45
- replace = true,
46
- only: onlyOption = [],
47
- shouldNavigate = true,
60
+ resetKeys = ['page'],
61
+ paused = false,
48
62
  debounce = {},
49
- arrayFormat = 'comma',
63
+ server = true,
64
+ visitOptions,
50
65
  } = options;
51
66
 
52
- const filterValidValues = (obj: T): Partial<T> => {
53
- const filtered: Partial<T> = {};
54
- Object.entries(obj).forEach(([key, val]) => {
55
- if (!isNil(val) && val !== '') {
56
- (filtered as Record<string, unknown>)[key] = val;
57
- }
58
- });
59
- return filtered;
60
- };
61
-
62
67
  /** Every key this composable has ever managed, for stable URL cleanup. */
63
68
  const seenKeys = new Set<string>(Object.keys(toValue(object) ?? {}));
64
69
 
65
- const navigate = (newObject: T) => {
66
- const validValues = filterValidValues(newObject);
67
- const transformedValues = transform ? transform(validValues) : validValues;
68
-
69
- // Start from the existing query to preserve keys owned elsewhere.
70
- const existingQuery = useQuery({ arrayFormat });
71
- seenKeys.forEach((key) => delete existingQuery[key]);
72
- if (resetPage) delete existingQuery.page;
73
- resetKeys.forEach((key) => delete existingQuery[key]);
74
-
75
- Object.entries(transformedValues).forEach(([key, val]) => {
76
- if (!isEmpty(val)) existingQuery[key] = val;
77
- });
70
+ /** A query in one canonical form, so encoding and key order never count. */
71
+ const canonical = (url: string) =>
72
+ qs.stringify(parseQuery(url), { arrayFormat: QUERY_ARRAY_FORMAT });
78
73
 
79
- const stringified = qs.stringify(existingQuery, { arrayFormat });
80
- const newUrl = stringified
81
- ? [window.location.pathname, stringified].join('?')
82
- : window.location.pathname;
74
+ const navigate = (newObject: T) => {
75
+ if (toValue(paused)) return;
83
76
 
84
- if (newUrl === usePage().url) return;
85
- if (!toValue(shouldNavigate)) return;
77
+ // Only the reset keys and the keys this object owns change; every
78
+ // other parameter (a tab, a sort) stays as it is.
79
+ const changes: Record<string, unknown> = {};
80
+ resetKeys.forEach((key) => (changes[key] = null));
81
+ seenKeys.forEach((key) => (changes[key] = newObject[key]));
82
+ const from = currentUrl();
83
+ const url = withQuery(from, changes);
84
+ if (canonical(url) === canonical(from)) return;
86
85
 
87
- router.visit(newUrl, {
88
- preserveState,
89
- preserveScroll,
90
- only: toValue(onlyOption),
91
- replace,
92
- });
86
+ visitUrl(
87
+ url,
88
+ {
89
+ preserveState: true,
90
+ preserveScroll: true,
91
+ replace: true,
92
+ ...toValue(visitOptions),
93
+ },
94
+ server
95
+ );
93
96
  };
94
97
 
95
98
  /** Each key's value as last seen, to tell which keys a change touched. */
@@ -102,8 +105,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
102
105
  onScopeDispose(() => clearTimeout(pending));
103
106
 
104
107
  watch(
105
- () => toValue(object),
106
- (newObject) => {
108
+ [() => toValue(object), () => toValue(paused)],
109
+ ([newObject, isPaused], [, wasPaused]) => {
107
110
  if (!newObject) return;
108
111
 
109
112
  Object.keys(newObject).forEach((key) => {
@@ -122,12 +125,15 @@ export function useQueryObject<T extends Record<string, unknown>>(
122
125
  );
123
126
  lastSeen = now;
124
127
  clearTimeout(pending);
128
+ if (isPaused) return;
129
+ // Resuming sends what changed while paused, without waiting.
130
+ const resumed = wasPaused && !isPaused;
125
131
  const waits = changed.map(
126
132
  (key) => (debounce as Record<string, number | undefined>)[key] ?? 0
127
133
  );
128
134
  // Only debounced keys changed: wait for the typing to settle, then
129
135
  // visit with whatever the object holds by then.
130
- if (changed.length && waits.every((ms) => ms > 0)) {
136
+ if (!resumed && changed.length && waits.every((ms) => ms > 0)) {
131
137
  pending = setTimeout(
132
138
  () => navigate(toValue(object)),
133
139
  Math.max(...waits)
@@ -141,7 +147,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
141
147
  * into one watcher run, so a `clearFilters()` that blanks three keys
142
148
  * fires ONE visit instead of three (measured: 3 writes → 3 runs under
143
149
  * `sync`, 1 under the default). The two it saves used to self-cancel,
144
- * but they were still sent.
150
+ * but they were still sent. Watching `paused` in the same source means
151
+ * resuming and the change that comes with it are one run, one visit.
145
152
  *
146
153
  * This does NOT make typing cheap on its own — one visit per settled
147
154
  * keystroke is still one visit per keystroke. `debounce` is for that.
@@ -1,7 +1,14 @@
1
1
  // inertia-helpers/useResourceContext.ts
2
2
  import { useLoading } from './useLoading';
3
3
  import { noop } from './utils';
4
- import { ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue';
4
+ import {
5
+ onScopeDispose,
6
+ ref,
7
+ toValue,
8
+ watch,
9
+ type MaybeRefOrGetter,
10
+ type Ref,
11
+ } from 'vue';
5
12
 
6
13
  type UseResourceContextOptions<T, ID> = {
7
14
  immediate?: boolean;
@@ -14,7 +21,8 @@ type UseResourceContextOptions<T, ID> = {
14
21
 
15
22
  export function useResourceContext<T, ID = string>(
16
23
  id: MaybeRefOrGetter<ID | null | undefined>,
17
- fn: (id: ID) => Promise<T>,
24
+ /** `signal` aborts when the load goes stale: pass it to `fetch` / axios. */
25
+ fn: (id: ID, signal: AbortSignal) => Promise<T>,
18
26
  options: UseResourceContextOptions<T, ID> = {}
19
27
  ) {
20
28
  const data = ref<T | null>(null) as Ref<T | null>;
@@ -28,31 +36,69 @@ export function useResourceContext<T, ID = string>(
28
36
  onFinish = noop,
29
37
  } = options;
30
38
 
31
- const { loading, fn: fetch } = useLoading((resourceId: ID) => fn(resourceId));
39
+ const { loading, fn: fetch } = useLoading(
40
+ (resourceId: ID, signal: AbortSignal) => fn(resourceId, signal)
41
+ );
42
+
43
+ // Each load takes a ticket; a newer load, an id change or the scope's end
44
+ // makes older tickets stale, and a stale response is dropped (no data, no
45
+ // callbacks): a slow answer for the row the user left never lands. Going
46
+ // stale also aborts its request.
47
+ let ticket = 0;
48
+ let controller: AbortController | null = null;
49
+ const stale = () => {
50
+ ticket++;
51
+ controller?.abort();
52
+ };
53
+ let loadedId: ID | null = null;
54
+ const clear = () => {
55
+ stale();
56
+ data.value = null;
57
+ loadedId = null;
58
+ };
59
+ onScopeDispose(stale);
32
60
 
33
61
  const load = async (resourceId: ID) => {
62
+ stale();
63
+ const own = ticket;
64
+ controller = new AbortController();
65
+ const outcome = await fetch(resourceId, controller.signal).then(
66
+ (result) => ({ ok: true as const, result }),
67
+ (error: unknown) => ({ ok: false as const, error })
68
+ );
69
+ if (own !== ticket) return;
34
70
  try {
35
- const result = await fetch(resourceId);
36
- data.value = result;
37
- await onSuccess(result, resourceId);
38
- } catch (error) {
39
- data.value = null;
40
- await onError(error, resourceId);
71
+ if (outcome.ok) {
72
+ data.value = outcome.result;
73
+ loadedId = resourceId;
74
+ // Outside the fetch's catch: a throwing onSuccess is the
75
+ // caller's bug, not a failed fetch.
76
+ await onSuccess(outcome.result, resourceId);
77
+ } else {
78
+ clear();
79
+ await onError(outcome.error, resourceId);
80
+ }
41
81
  } finally {
42
82
  await onFinish(resourceId);
43
83
  }
44
84
  };
45
85
 
86
+ // Per-item sources: a re-run that yields the same id and flag (a partial
87
+ // reload handing a new row object with the same id) does not refetch.
46
88
  watch(
47
- () => ({ resourceId: toValue(id), isDisabled: toValue(disabled) }),
48
- async ({ resourceId, isDisabled }) => {
89
+ [() => toValue(id), () => toValue(disabled)],
90
+ async ([resourceId, isDisabled]) => {
49
91
  if (!resourceId) {
50
- data.value = null;
92
+ clear();
51
93
  await options.onReset?.();
52
94
  return;
53
95
  }
54
- // Keep already-loaded data while disabled.
55
- if (isDisabled) return;
96
+ // Keep already-loaded data while disabled, but only for the id it
97
+ // was loaded for: never show row a's resource under row b.
98
+ if (isDisabled) {
99
+ if (resourceId !== loadedId) clear();
100
+ return;
101
+ }
56
102
  await load(resourceId as ID);
57
103
  },
58
104
  { immediate }
@@ -1,6 +1,7 @@
1
1
  // inertia-helpers/useRouteQuery.ts
2
2
  // requires: npm i query-string
3
- import { router, usePage } from '@inertiajs/vue3';
3
+ import { currentUrl, splitUrl, visitUrl, withQuery } from './useQuery';
4
+ import type { VisitOptions } from '@inertiajs/core';
4
5
  import qs from 'query-string';
5
6
  import {
6
7
  computed,
@@ -12,8 +13,15 @@ import {
12
13
  type Options<T> = {
13
14
  /** Parse the raw string (e.g. `Number`). Defaults to identity. */
14
15
  transform?: (value: string) => T;
15
- preserveScroll?: boolean;
16
- preserveState?: boolean;
16
+ /** `false` writes the URL with no request. Default `true`. */
17
+ server?: boolean;
18
+ /**
19
+ * Merged over `{ preserveState, preserveScroll, replace: true }`. With
20
+ * `server: false` only those three apply.
21
+ */
22
+ visitOptions?: MaybeRefOrGetter<
23
+ Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
24
+ >;
17
25
  };
18
26
 
19
27
  export function useRouteQuery<T = string>(
@@ -24,37 +32,34 @@ export function useRouteQuery<T = string>(
24
32
  ): WritableComputedRef<T> {
25
33
  const {
26
34
  transform = (value) => value as unknown as T,
27
- preserveScroll = true,
28
- preserveState = true,
35
+ server = true,
36
+ visitOptions,
29
37
  } = options;
30
- const page = usePage();
31
38
 
32
39
  const readRaw = (): string => {
33
- const [, queryString = ''] = page.url.split('?');
34
- const raw = qs.parse(queryString)[key];
35
- const single = Array.isArray(raw) ? raw[0] : raw;
40
+ const raw = qs.parse(splitUrl(currentUrl()).query)[key];
41
+ // A repeated key reads its last value, as PHP does.
42
+ const single = Array.isArray(raw) ? raw.at(-1) : raw;
36
43
  return single != null ? String(single) : toValue(defaultValue);
37
44
  };
38
45
 
39
46
  return computed<T>({
40
47
  get: () => transform(readRaw()),
41
48
  set: (next) => {
42
- const nextRaw = String(next);
49
+ // `null` / `undefined` mean "no value": the key leaves the URL.
50
+ const nextRaw = next == null ? toValue(defaultValue) : String(next);
43
51
  if (nextRaw === readRaw()) return;
44
-
45
- const [path = '', queryString = ''] = page.url.split('?');
46
- const query = qs.parse(queryString);
47
- if (nextRaw === toValue(defaultValue)) {
48
- delete query[key]; // keep URLs clean at the default
49
- } else {
50
- query[key] = nextRaw;
51
- }
52
- const stringified = qs.stringify(query);
53
- router.visit(stringified ? `${path}?${stringified}` : path, {
54
- preserveScroll,
55
- preserveState,
56
- replace: true,
57
- });
52
+ const value = nextRaw === toValue(defaultValue) ? null : nextRaw; // keep URLs clean at the default
53
+ visitUrl(
54
+ withQuery(currentUrl(), { [key]: value }),
55
+ {
56
+ preserveScroll: true,
57
+ preserveState: true,
58
+ replace: true,
59
+ ...toValue(visitOptions),
60
+ },
61
+ server
62
+ );
58
63
  },
59
64
  });
60
65
  }
@@ -1,21 +1,23 @@
1
1
  // inertia-helpers/useScreenSizes.ts
2
2
  import { createSharedComposable, useWindowSize } from '@vueuse/core';
3
+ import { useMobile } from 'bitboss-ui';
3
4
  import { computed } from 'vue';
4
5
 
5
- /** Tailwind md breakpoint (px). Mobile when width < 768. */
6
- export const SCREEN_BREAKPOINT_MD = 768;
7
- /** Tailwind lg breakpoint (px). Tablet when 768 <= width < 1024. */
6
+ /** Tailwind lg breakpoint (px). Tablet from the library's mobile cut up to here. */
8
7
  export const SCREEN_BREAKPOINT_LG = 1024;
9
8
 
10
9
  const useScreenSizesImpl = () => {
11
10
  const { width } = useWindowSize();
11
+ // The library's own signal (below the plugin's `mobileMaxWidth`), so a page
12
+ // and the components on it never disagree about "mobile".
13
+ const { isMobile } = useMobile();
12
14
 
13
- const isMobile = computed(() => width.value < SCREEN_BREAKPOINT_MD);
14
15
  const isTablet = computed(
15
- () =>
16
- width.value >= SCREEN_BREAKPOINT_MD && width.value < SCREEN_BREAKPOINT_LG
16
+ () => !isMobile.value && width.value < SCREEN_BREAKPOINT_LG
17
+ );
18
+ const isDesktop = computed(
19
+ () => !isMobile.value && width.value >= SCREEN_BREAKPOINT_LG
17
20
  );
18
- const isDesktop = computed(() => width.value >= SCREEN_BREAKPOINT_LG);
19
21
 
20
22
  return { width, isMobile, isTablet, isDesktop };
21
23
  };
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  usePaginatedResponseContext,
4
4
  type PaginatedResponseContext,
5
+ type PaginatedResponseOptions,
5
6
  } from './usePaginatedResponseContext';
6
7
  import {
7
8
  useTableSelectionContext,
@@ -16,7 +17,9 @@ export type TableContext<T, U> = TableSelectionContext<U> &
16
17
  const toTableKey = (key: string): string => `${key}:table`;
17
18
 
18
19
  export const useTableContext = <T = object, U = T>(
19
- dataKey: string
20
+ dataKey: string,
21
+ /** For the paginated half: query keys and pager visit options. */
22
+ options?: PaginatedResponseOptions
20
23
  ): TableContext<T, U> => {
21
24
  const existing = injectLocal<TableContext<T, U> | null>(
22
25
  toTableKey(dataKey),
@@ -26,7 +29,7 @@ export const useTableContext = <T = object, U = T>(
26
29
 
27
30
  const context: TableContext<T, U> = {
28
31
  ...useTableSelectionContext<U>(dataKey),
29
- ...usePaginatedResponseContext<T>(dataKey),
32
+ ...usePaginatedResponseContext<T>(dataKey, options),
30
33
  };
31
34
 
32
35
  provideLocal(toTableKey(dataKey), context);
@@ -22,3 +22,17 @@ export const isPlainObject = (
22
22
  typeof value === 'object' &&
23
23
  value !== null &&
24
24
  Object.getPrototypeOf(value) === Object.prototype;
25
+
26
+ /**
27
+ * Deep copy of server data (arrays and plain objects; anything else, e.g. a
28
+ * Date an app put there, is shared). Reads through Vue proxies, which
29
+ * `structuredClone` refuses.
30
+ */
31
+ export const cloneDeep = <T>(value: T): T =>
32
+ (Array.isArray(value)
33
+ ? value.map(cloneDeep)
34
+ : isPlainObject(value)
35
+ ? Object.fromEntries(
36
+ Object.entries(value).map(([key, item]) => [key, cloneDeep(item)])
37
+ )
38
+ : value) as T;
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "library": "bitboss-ui",
4
- "version": "3.0.0-beta.64",
5
- "generatedAt": "2026-10-26T22:25:55.188Z",
4
+ "version": "3.0.0-beta.65",
5
+ "generatedAt": "2026-10-27T14:11:46.011Z",
6
6
  "about": "The bitboss-ui API for agents: open components/<Name>.json for one component (every prop, event, slot, v-model, pt part and state, and the public types its API uses; one member per line; a row or pt part written { name, common: true } is whole once in common.json, read it once). components/<Name>.tree.json has its component tree and CSS internals; files.removed the old names. The machine file with everything in one place is ../components.json.",
7
7
  "reading": {
8
8
  "components[]": "one row per component; description = its first sentence. Its API file is files.component with <Name> = name; its components/<Name>.tree.json (component tree, css classes, CSS custom properties) exists unless the row says tree: false.",
@@ -351,7 +351,7 @@ export function provideApprovalsInbox(): ApprovalsInboxContext {
351
351
  });
352
352
  // The search waits for typing to settle; every other filter visits at once.
353
353
  useQueryObject(filters, {
354
- only: [...RELOAD_PROPS],
354
+ visitOptions: { only: [...RELOAD_PROPS] },
355
355
  debounce: { search: 300 },
356
356
  });
357
357
 
@@ -495,7 +495,7 @@ export function provideBacklogWorkspace(): BacklogWorkspaceContext {
495
495
  : String(filters.assignee.id ?? 'none'),
496
496
  }),
497
497
  // The search waits for typing to settle; every other filter visits at once.
498
- { only: [...RELOAD_PROPS], debounce: { search: 300 } }
498
+ { visitOptions: { only: [...RELOAD_PROPS] }, debounce: { search: 300 } }
499
499
  );
500
500
 
501
501
  const hasActiveFilters = computed(
@@ -334,7 +334,7 @@ export function provideOwnershipAtlas(): OwnershipAtlasContext {
334
334
  });
335
335
  // The search waits for typing to settle; every other filter visits at once.
336
336
  useQueryObject(filters, {
337
- only: [...RELOAD_PROPS],
337
+ visitOptions: { only: [...RELOAD_PROPS] },
338
338
  debounce: { search: 300 },
339
339
  });
340
340
 
@@ -346,7 +346,7 @@ export function provideInvoicesWorkspace(): InvoicesWorkspaceContext {
346
346
  });
347
347
  // The search waits for typing to settle; every other filter visits at once.
348
348
  useQueryObject(filters, {
349
- only: [...RELOAD_PROPS],
349
+ visitOptions: { only: [...RELOAD_PROPS] },
350
350
  debounce: { search: 300 },
351
351
  });
352
352
 
@@ -361,7 +361,7 @@ export function provideUploadCenter(): UploadCenterContext {
361
361
  });
362
362
  // The search waits for typing to settle; every other filter visits at once.
363
363
  useQueryObject(filters, {
364
- only: [...RELOAD_PROPS],
364
+ visitOptions: { only: [...RELOAD_PROPS] },
365
365
  debounce: { search: 300 },
366
366
  });
367
367
 
@@ -988,8 +988,32 @@ const typeahead = useTypeahead<BaseOption<Item>>({
988
988
  },
989
989
  locale: () => config.locale,
990
990
  });
991
+ /**
992
+ * A key typed while a keyboard open is still placing focus belongs to the
993
+ * search field it is about to reach: it waits for the open, then is written
994
+ * there as an input, so typing straight after Enter is not lost (MW-106).
995
+ */
996
+ const typeIntoSearchAfterOpen = async (event: KeyboardEvent) => {
997
+ event.preventDefault();
998
+ await afterKeyboardOpen();
999
+ const input = searchInput.value;
1000
+ if (!(input instanceof HTMLInputElement)) return;
1001
+ if (document.activeElement !== input) return;
1002
+ const end = input.value.length;
1003
+ input.setRangeText(
1004
+ event.key,
1005
+ input.selectionStart ?? end,
1006
+ input.selectionEnd ?? end,
1007
+ 'end'
1008
+ );
1009
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1010
+ };
991
1011
  const onTypeaheadKey = async (event: KeyboardEvent) => {
992
- if (computedAllowWriting.value || !isPrintableTypeaheadKey(event)) return;
1012
+ if (!isPrintableTypeaheadKey(event)) return;
1013
+ if (computedAllowWriting.value) {
1014
+ if (keyboardOpen) await typeIntoSearchAfterOpen(event);
1015
+ return;
1016
+ }
993
1017
  if (!canProcessKeyboardBindings.value) return;
994
1018
  event.preventDefault();
995
1019
  if (!shown.value) {