nuxt-ui-tools 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +2 -1
  3. package/dist/runtime/dashboard/composables/use-dashboard-remote-options.js +8 -3
  4. package/dist/runtime/dashboard/types/filters.d.ts +7 -1
  5. package/dist/runtime/dashboard/utils/builders/dashboard-filters.js +19 -2
  6. package/dist/runtime/form/composables/use-field-options.js +19 -0
  7. package/dist/runtime/form/composables/use-remote-field-options.js +21 -2
  8. package/dist/runtime/form/types/options.d.ts +22 -2
  9. package/dist/runtime/shared/index.d.ts +1 -0
  10. package/dist/runtime/shared/index.js +1 -0
  11. package/dist/runtime/shared/types/query.d.ts +5 -1
  12. package/dist/runtime/shared/types/remote-options.d.ts +56 -0
  13. package/dist/runtime/shared/utils/define-remote-options.d.ts +23 -0
  14. package/dist/runtime/shared/utils/define-remote-options.js +24 -0
  15. package/dist/runtime/shared/utils/remote-options-query.d.ts +6 -0
  16. package/dist/runtime/shared/utils/remote-options-query.js +18 -0
  17. package/dist/runtime/table/components/data-list/data-list-filter-tags.d.vue.ts +1 -1
  18. package/dist/runtime/table/components/data-list/data-list-filter-tags.vue +1 -1
  19. package/dist/runtime/table/components/data-list/data-list-filter-tags.vue.d.ts +1 -1
  20. package/dist/runtime/table/components/filters/panel/filter-panel.vue +14 -2
  21. package/dist/runtime/table/components/filters/shared/filter-option-multiple-list.vue +2 -2
  22. package/dist/runtime/table/components/filters/shared/filter-option-single-list.vue +2 -2
  23. package/dist/runtime/table/components/filters/shared/filter-option-tree-content.vue +5 -5
  24. package/dist/runtime/table/components/filters/shared/filter-trigger-tag.vue +7 -1
  25. package/dist/runtime/table/composables/use-table-filter-selected-options.js +16 -1
  26. package/dist/runtime/table/types/filters.d.ts +7 -0
  27. package/dist/runtime/table/types/remote-options.d.ts +3 -16
  28. package/dist/runtime/table/utils/remote-table-options.js +37 -37
  29. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "configKey": "nuxtUiTools",
6
6
  "docs": "https://ui.nuxt.com/docs/getting-started/installation/nuxt",
7
7
  "name": "nuxt-ui-tools",
8
- "version": "1.4.0",
8
+ "version": "1.5.0",
9
9
  "builder": {
10
10
  "@nuxt/module-builder": "1.0.3",
11
11
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -143,8 +143,9 @@ function setupImports(runtimeDir) {
143
143
  { from: "i18n", name: "provideUiToolsLocale" },
144
144
  { from: "i18n", name: "useUiToolsLocale" }
145
145
  ]),
146
- // Shared responsive helpers
146
+ // Shared runtime
147
147
  ...withRuntime(runtimeDir, [
148
+ { from: "shared", name: "defineRemoteOptions" },
148
149
  { from: "shared", name: "getResponsiveValue" },
149
150
  { from: "shared", name: "parseResponsiveValue" },
150
151
  { from: "shared", name: "resolveResponsiveValueAtBreakpoint" },
@@ -10,7 +10,6 @@ import { mergeDashboardOptions, resolveDashboardOptionValues } from "../utils/op
10
10
  import { runDashboardRemoteResult } from "../utils/remote.js";
11
11
  export function useDashboardRemoteOptions(params) {
12
12
  const { config } = params;
13
- const queryKey = config.queryKey ?? params.queryKey;
14
13
  const pageSize = Math.max(
15
14
  1,
16
15
  config.pagination?.size ?? DASHBOARD_REMOTE_OPTIONS_DEFAULTS.pageSize
@@ -22,6 +21,12 @@ export function useDashboardRemoteOptions(params) {
22
21
  computed(() => search.value.trim()),
23
22
  config.search?.debounce ?? DASHBOARD_REMOTE_OPTIONS_DEFAULTS.searchDebounce
24
23
  );
24
+ function queryKeyFor(term2) {
25
+ return config.queryKeyFor?.({
26
+ page: { ...REMOTE_OPTIONS_FIRST_PAGE, size: pageSize },
27
+ search: term2
28
+ }) ?? config.queryKey ?? params.queryKey;
29
+ }
25
30
  const pages = useInfiniteQuery(
26
31
  computed(() => ({
27
32
  enabled: (open.value || search.value !== "") && (term.value.length === 0 || term.value.length >= minLength),
@@ -33,7 +38,7 @@ export function useDashboardRemoteOptions(params) {
33
38
  config.load({ page: { ...context.pageParam, size: pageSize }, search: term.value }),
34
39
  context
35
40
  ),
36
- queryKey: [...queryKey, "options", term.value],
41
+ queryKey: config.queryKeyFor ? [...queryKeyFor(term.value), { remoteOptionPages: pageSize }] : [...queryKeyFor(term.value), "options", term.value],
37
42
  staleTime: DASHBOARD_REMOTE_OPTIONS_DEFAULTS.staleTime
38
43
  }))
39
44
  );
@@ -50,7 +55,7 @@ export function useDashboardRemoteOptions(params) {
50
55
  computed(() => ({
51
56
  enabled: Boolean(config.resolveSelected) && missing.value.length > 0,
52
57
  queryFn: (context) => config.resolveSelected ? runDashboardRemoteResult(config.resolveSelected({ values: missing.value }), context) : Promise.resolve([]),
53
- queryKey: [...queryKey, "selected", missing.value],
58
+ queryKey: config.selectedQueryKeyFor ? config.selectedQueryKeyFor({ values: missing.value }) : [...queryKeyFor(""), "selected", missing.value],
54
59
  // Hydrated labels stay while the next set of missing values resolves.
55
60
  placeholderData: (previous) => previous,
56
61
  staleTime: DASHBOARD_REMOTE_OPTIONS_DEFAULTS.selectedStaleTime
@@ -150,8 +150,14 @@ export interface DashboardRemoteOptionsConfig {
150
150
  /** Defaults to `{ type: 'page', size: 25 }`. */
151
151
  pagination?: RemoteOptionsPagination;
152
152
  search?: RemoteOptionsSearch;
153
- /** Cache identity of the option source. Defaults to a key derived from the filter location. */
153
+ /** Cache identity of an inline source. Defaults to a key derived from the filter location. */
154
154
  queryKey?: QueryKey;
155
+ /** Cache identity of a reusable loader, derived from its endpoint request and current search. */
156
+ queryKeyFor?: (request: DashboardRemoteOptionsRequest) => QueryKey;
157
+ /** Cache identity of a selected lookup, including scope absent from the page endpoint. */
158
+ selectedQueryKeyFor?: (request: {
159
+ values: readonly string[];
160
+ }) => QueryKey;
155
161
  }
156
162
  /** Option items of a filter: fixed, or read reactively (e.g. from another query's data). */
157
163
  export type DashboardFilterItems = readonly DashboardOption[] | (() => readonly DashboardOption[]);
@@ -79,8 +79,25 @@ function optionsFilter(items, options) {
79
79
  return defineFilter("options", codec, { ...options, items });
80
80
  }
81
81
  function remoteFilter(config, extra) {
82
- const { load, pagination, queryKey, resolveSelected, search, ...options } = config;
83
- const remote = { load, pagination, queryKey, resolveSelected, search };
82
+ const {
83
+ load,
84
+ pagination,
85
+ queryKey,
86
+ queryKeyFor,
87
+ resolveSelected,
88
+ search,
89
+ selectedQueryKeyFor,
90
+ ...options
91
+ } = config;
92
+ const remote = {
93
+ load,
94
+ pagination,
95
+ queryKey,
96
+ queryKeyFor,
97
+ resolveSelected,
98
+ search,
99
+ selectedQueryKeyFor
100
+ };
84
101
  return defineFilter("remote", optionalStringCodec, {
85
102
  searchable: true,
86
103
  ...options,
@@ -350,8 +350,27 @@ function resolveRemoteOptionConfig(field) {
350
350
  return null;
351
351
  }
352
352
  const options = Object.getOwnPropertyDescriptor(field, "options")?.value;
353
+ if (isRemoteLoaderOptionConfig(options)) {
354
+ const { loader, ...fieldOptions } = options;
355
+ return {
356
+ ...fieldOptions,
357
+ pagination: loader.pagination,
358
+ queryKeyFor: loader.queryKeyFor,
359
+ resolveSelected: loader.resolveSelected,
360
+ search: loader.search,
361
+ selectedQueryKeyFor: loader.selectedQueryKeyFor,
362
+ source: loader.load
363
+ };
364
+ }
353
365
  return isRemoteOptionConfig(options) ? options : null;
354
366
  }
367
+ function isRemoteLoaderOptionConfig(value) {
368
+ if (!isRecord(value) || value.mode !== "remote" || !isRecord(value.loader)) {
369
+ return false;
370
+ }
371
+ const { loader } = value;
372
+ return isFunction(loader.load) && isFunction(loader.resolveSelected) && isRecord(loader.pagination);
373
+ }
355
374
  function isRemoteOptionConfig(value) {
356
375
  return isRecord(value) && value.mode === "remote" && isFunction(Object.getOwnPropertyDescriptor(value, "source")?.value) && isRecord(value.pagination);
357
376
  }
@@ -1,4 +1,4 @@
1
- import { useQueryClient } from "@tanstack/vue-query";
1
+ import { hashKey, useQueryClient } from "@tanstack/vue-query";
2
2
  import { debounceFilter, watchWithFilter } from "@vueuse/core";
3
3
  import { computed, ref, shallowRef, watch } from "vue";
4
4
  import { formOptionKey, mergeResolvedOptions, normalizeOptionItems } from "../utils/options.js";
@@ -384,7 +384,7 @@ export function useRemoteFieldOptions(params) {
384
384
  selectedError.value = null;
385
385
  return;
386
386
  }
387
- retainSelected([]);
387
+ retainSelected(loaded.value);
388
388
  if (missingSelected.value.length) {
389
389
  void hydrateSelected({ reconcile: false });
390
390
  }
@@ -410,6 +410,25 @@ export function useRemoteFieldOptions(params) {
410
410
  },
411
411
  { deep: true }
412
412
  );
413
+ const pageIdentity = computed(() => {
414
+ const config = params.config.value;
415
+ return config?.queryKeyFor ? hashKey(
416
+ config.queryKeyFor({
417
+ page: { cursor: null, index: 1, size: pageSize.value },
418
+ search: ""
419
+ })
420
+ ) : null;
421
+ });
422
+ const selectedIdentity = computed(() => {
423
+ const config = params.config.value;
424
+ return config?.selectedQueryKeyFor ? hashKey(config.selectedQueryKeyFor({ values: [] })) : null;
425
+ });
426
+ watch([pageIdentity, selectedIdentity], ([page, selected], [previousPage, previousSelected]) => {
427
+ if (page === previousPage && selected === previousSelected) return;
428
+ selectionGeneration.value += 1;
429
+ if (page !== previousPage) void reload({ clearSearch: false });
430
+ if (selectedValues.value.length) void hydrateSelected({ reconcile: true });
431
+ });
413
432
  return {
414
433
  activate,
415
434
  error: errorValue,
@@ -1,4 +1,5 @@
1
- import type { RemoteOptionsPageRequest, RemoteOptionsPagination, RemoteOptionsResult, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
1
+ import type { QueryKey } from '@tanstack/vue-query';
2
+ import type { RemoteOptionsLoader, RemoteOptionsPageRequest, RemoteOptionsPagination, RemoteOptionsResult, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
2
3
  import type { FormValue } from './/index.js';
3
4
  import type { FormFieldCallback, FormFieldCallbackParams } from './callbacks.js';
4
5
  import type { FormQueryOptions } from './context.js';
@@ -92,6 +93,15 @@ export interface FormRemoteOptionConfig<TOption, TContext = NonNullable<unknown>
92
93
  resolveSelected?: (request: FormRemoteSelectedRequest<TContext, TDeps, TValue, TOption>) => FormRemoteSource<readonly TOption[]>;
93
94
  pagination: RemoteOptionsPagination;
94
95
  search?: RemoteOptionsSearch;
96
+ /** Identity of the first page, so reusable loaders follow external scope changes. */
97
+ queryKeyFor?: (request: {
98
+ search: string;
99
+ page: RemoteOptionsPageRequest;
100
+ }) => QueryKey;
101
+ /** Identity of selected labels, including scope absent from the page endpoint. */
102
+ selectedQueryKeyFor?: (request: {
103
+ values: readonly FormOptionValue[];
104
+ }) => QueryKey;
95
105
  /** Dependency aliases whose changes reset the loaded pages and re-run selected hydration. */
96
106
  refreshOn?: readonly string[];
97
107
  /** Clears selected values the latest successful selected hydration did not return. Defaults to `false`. */
@@ -103,4 +113,14 @@ export interface FormRemoteOptionConfig<TOption, TContext = NonNullable<unknown>
103
113
  /** Disable the field while its option source has no usable data yet. */
104
114
  disableOnLoading?: boolean;
105
115
  }
106
- export type FormAnyOptionConfig<TOption, TContext = NonNullable<unknown>, TDeps = NonNullable<unknown>, TValue = FormValue> = FormOptionConfig<TOption, TContext, TDeps, TValue> | FormRemoteOptionConfig<TOption, TContext, TDeps, TValue> | FormOptionsSource<TOption, TContext, TDeps, TValue>;
116
+ /** Reuses one remote loader while keeping field-specific refresh and creation behavior local. */
117
+ export interface FormRemoteLoaderOptionConfig<TOption, TContext = NonNullable<unknown>, TDeps = NonNullable<unknown>, TValue = FormValue> extends Omit<FormRemoteOptionConfig<TOption, TContext, TDeps, TValue>, 'source' | 'resolveSelected' | 'pagination' | 'search' | 'queryKeyFor' | 'selectedQueryKeyFor'> {
118
+ loader: RemoteOptionsLoader<TOption>;
119
+ source?: never;
120
+ resolveSelected?: never;
121
+ pagination?: never;
122
+ search?: never;
123
+ queryKeyFor?: never;
124
+ selectedQueryKeyFor?: never;
125
+ }
126
+ export type FormAnyOptionConfig<TOption, TContext = NonNullable<unknown>, TDeps = NonNullable<unknown>, TValue = FormValue> = FormOptionConfig<TOption, TContext, TDeps, TValue> | FormRemoteOptionConfig<TOption, TContext, TDeps, TValue> | FormRemoteLoaderOptionConfig<TOption, TContext, TDeps, TValue> | FormOptionsSource<TOption, TContext, TDeps, TValue>;
@@ -1,5 +1,6 @@
1
1
  export * from './composables/use-responsive-value.js';
2
2
  export * from './composables/use-range-select.js';
3
+ export { defineRemoteOptions } from './utils/define-remote-options.js';
3
4
  export * from './types/responsive.js';
4
5
  export * from './types/utils.js';
5
6
  export * from './utils/object.js';
@@ -1,5 +1,6 @@
1
1
  export * from "./composables/use-responsive-value.js";
2
2
  export * from "./composables/use-range-select.js";
3
+ export { defineRemoteOptions } from "./utils/define-remote-options.js";
3
4
  export * from "./types/responsive.js";
4
5
  export * from "./types/utils.js";
5
6
  export * from "./utils/object.js";
@@ -1,4 +1,4 @@
1
- import type { QueryFunction, QueryKey, UseQueryOptions } from '@tanstack/vue-query';
1
+ import type { QueryFunction, QueryKey, QueryOptions, UseQueryOptions } from '@tanstack/vue-query';
2
2
  /**
3
3
  * TanStack query definition accepted by runtime domains that own the `useQuery` call themselves
4
4
  * (table sources, dashboard queries): an explicit `queryKey`, an optional `queryFn`, and any other
@@ -16,3 +16,7 @@ export interface QueryFnDefinition<TData = unknown> {
16
16
  queryKey: QueryKey;
17
17
  queryFn: QueryFunction<TData, QueryKey, string | null>;
18
18
  }
19
+ /** Data returned by a query function, including generated options whose function may be a ref. */
20
+ export type QueryFunctionResult<TQuery> = TQuery extends {
21
+ queryFn: (...args: never[]) => infer TResult;
22
+ } ? Awaited<TResult> : TQuery extends QueryOptions<infer TQueryFnData, infer _TError, infer _TData, infer _TQueryData, infer _TQueryKey> ? Awaited<TQueryFnData> : TQuery extends UseQueryOptions<infer _TQueryFnData, infer _TError, infer TData, infer _TQueryData, infer _TQueryKey> ? Awaited<TData> : never;
@@ -1,3 +1,11 @@
1
+ import type { QueryKey } from '@tanstack/vue-query';
2
+ import type { QueryFnDefinition, QueryFunctionResult } from './query.js';
3
+ export type RemoteOptionValue = string | number | boolean;
4
+ /** Option shape shared by dashboard, table, and form remote pickers. */
5
+ export interface RemoteOption<TValue extends RemoteOptionValue = RemoteOptionValue> {
6
+ value: TValue;
7
+ label: string;
8
+ }
1
9
  /**
2
10
  * Page envelope returned by an index-paginated remote option source.
3
11
  */
@@ -35,3 +43,51 @@ export interface RemoteOptionsSearch {
35
43
  /** Minimum term length before a search request runs. Defaults to 0. */
36
44
  minLength?: number;
37
45
  }
46
+ /** One reusable, query-backed option source. Inline remote definitions remain supported. */
47
+ export interface RemoteOptionsLoader<TOption> {
48
+ load: (request: {
49
+ search: string;
50
+ page: RemoteOptionsPageRequest;
51
+ }) => QueryFnDefinition<RemoteOptionsResult<TOption>>;
52
+ resolveSelected: (request: {
53
+ values: readonly RemoteOptionValue[];
54
+ }) => QueryFnDefinition<readonly TOption[]>;
55
+ pagination: RemoteOptionsPagination;
56
+ search?: RemoteOptionsSearch;
57
+ /** Identity of a page query, used by pickers that cache an infinite list as one query. */
58
+ queryKeyFor?: (request: {
59
+ search: string;
60
+ page: RemoteOptionsPageRequest;
61
+ }) => QueryKey;
62
+ /** Identity of a selected-value lookup, including any scope used only by that endpoint. */
63
+ selectedQueryKeyFor?: (request: {
64
+ values: readonly RemoteOptionValue[];
65
+ }) => QueryKey;
66
+ }
67
+ /** Query options returned by an endpoint or generated client. */
68
+ export interface RemoteOptionsQueryDefinition {
69
+ queryKey: QueryKey;
70
+ }
71
+ /** Endpoint query definitions; supplied before mapping so their result types are inferred first. */
72
+ export interface RemoteOptionsQueries<TPageQuery extends RemoteOptionsQueryDefinition, TSelectedQuery extends RemoteOptionsQueryDefinition> {
73
+ load: (request: {
74
+ search: string;
75
+ page: RemoteOptionsPageRequest;
76
+ }) => TPageQuery;
77
+ resolveSelected: (request: {
78
+ values: readonly RemoteOptionValue[];
79
+ }) => TSelectedQuery;
80
+ }
81
+ /** Maps endpoint responses to the option result shared by all three pickers. */
82
+ export interface DefineRemoteOptionsConfig<TPageQuery extends RemoteOptionsQueryDefinition, TSelectedQuery extends RemoteOptionsQueryDefinition, TOption extends RemoteOption> {
83
+ /** Stable namespace for mapped query results; requests still use their endpoint query keys. */
84
+ key: string;
85
+ /** Maps the endpoint page to options and the next-page marker. */
86
+ mapPage: (result: QueryFunctionResult<TPageQuery>) => RemoteOptionsResult<TOption>;
87
+ /** Maps selected lookup results to option items. */
88
+ mapSelected: (result: QueryFunctionResult<TSelectedQuery>) => readonly NoInfer<TOption>[];
89
+ /** Page type and size expected by the endpoint. */
90
+ pagination: RemoteOptionsPagination;
91
+ /** Debounce and minimum term length applied by the consuming picker. */
92
+ search?: RemoteOptionsSearch;
93
+ }
@@ -0,0 +1,23 @@
1
+ import type { DefineRemoteOptionsConfig, RemoteOption, RemoteOptionsLoader, RemoteOptionsQueries, RemoteOptionsQueryDefinition } from '../types/remote-options.js';
2
+ /**
3
+ * Defines a query-backed option loader once for dashboard filters, table filters, and form fields.
4
+ * The endpoint's query key separates requests; `key` separates mapped option data from other
5
+ * consumers of that endpoint. Inline remote definitions continue to work in each domain.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const users = defineRemoteOptions({
10
+ * load: ({ search, page }) => api.users.list.queryOptions({ search, cursor: page.cursor }),
11
+ * resolveSelected: ({ values }) => api.users.byIds.queryOptions({ ids: values }),
12
+ * }, {
13
+ * key: 'users',
14
+ * mapPage: ({ rows, nextCursor }) => ({
15
+ * options: rows.map((user) => ({ value: user.id, label: user.name })),
16
+ * nextCursor,
17
+ * }),
18
+ * mapSelected: ({ rows }) => rows.map((user) => ({ value: user.id, label: user.name })),
19
+ * pagination: { type: 'cursor', size: 25 },
20
+ * })
21
+ * ```
22
+ */
23
+ export declare function defineRemoteOptions<TPageQuery extends RemoteOptionsQueryDefinition, TSelectedQuery extends RemoteOptionsQueryDefinition, const TOption extends RemoteOption>(queries: RemoteOptionsQueries<TPageQuery, TSelectedQuery>, config: DefineRemoteOptionsConfig<TPageQuery, TSelectedQuery, TOption>): RemoteOptionsLoader<TOption>;
@@ -0,0 +1,24 @@
1
+ import { mapRemoteOptionsQuery } from "./remote-options-query.js";
2
+ export function defineRemoteOptions(queries, config) {
3
+ const suffix = ["remote-options", config.key];
4
+ return {
5
+ load: (request) => mapRemoteOptionsQuery(
6
+ queries.load(request),
7
+ config.mapPage,
8
+ [...suffix, "page"]
9
+ ),
10
+ pagination: config.pagination,
11
+ queryKeyFor: (request) => [...queries.load(request).queryKey, ...suffix, "page"],
12
+ resolveSelected: (request) => mapRemoteOptionsQuery(
13
+ queries.resolveSelected(request),
14
+ config.mapSelected,
15
+ [...suffix, "selected"]
16
+ ),
17
+ selectedQueryKeyFor: (request) => [
18
+ ...queries.resolveSelected(request).queryKey,
19
+ ...suffix,
20
+ "selected"
21
+ ],
22
+ search: config.search
23
+ };
24
+ }
@@ -0,0 +1,6 @@
1
+ import type { QueryKey } from '@tanstack/vue-query';
2
+ import type { QueryFnDefinition } from '../types/query.js';
3
+ /** Maps an endpoint query while keeping its own key and cancellation context. */
4
+ export declare function mapRemoteOptionsQuery<TData, TResult>(definition: {
5
+ queryKey: QueryKey;
6
+ }, map: (result: TData) => TResult, suffix: readonly unknown[]): QueryFnDefinition<TResult>;
@@ -0,0 +1,18 @@
1
+ import { isFunction } from "./predicate.js";
2
+ export function mapRemoteOptionsQuery(definition, map, suffix) {
3
+ return {
4
+ async queryFn(context) {
5
+ if (!hasQueryFunction(definition)) {
6
+ throw new Error(
7
+ "[remote options] `load` and `resolveSelected` must return a query with a queryFn."
8
+ );
9
+ }
10
+ const result = await definition.queryFn({ ...context, queryKey: definition.queryKey });
11
+ return map(result);
12
+ },
13
+ queryKey: [...definition.queryKey, ...suffix]
14
+ };
15
+ }
16
+ function hasQueryFunction(definition) {
17
+ return "queryFn" in definition && isFunction(definition.queryFn);
18
+ }
@@ -2,7 +2,7 @@ import type { DataListControlSize, DataListFilterTagsUi } from '../../types/inde
2
2
  type __VLS_Props = {
3
3
  showAdd?: boolean;
4
4
  showClear?: boolean;
5
- /** `sheet` collapses the tags into a bottom sheet on mobile; `tags` keeps inline tags everywhere. */
5
+ /** `tags` keeps inline tags on mobile (default); `sheet` moves all filters into a bottom sheet. */
6
6
  mobile?: 'sheet' | 'tags';
7
7
  size?: DataListControlSize;
8
8
  ui?: DataListFilterTagsUi;
@@ -9,7 +9,7 @@ import FilterTagsBar from "../filters/tags/filter-tags-bar.vue";
9
9
  const props = defineProps({
10
10
  showAdd: { type: Boolean, required: false, default: false },
11
11
  showClear: { type: Boolean, required: false, default: false },
12
- mobile: { type: String, required: false, default: "sheet" },
12
+ mobile: { type: String, required: false, default: "tags" },
13
13
  size: { type: String, required: false },
14
14
  ui: { type: Object, required: false }
15
15
  });
@@ -2,7 +2,7 @@ import type { DataListControlSize, DataListFilterTagsUi } from '../../types/inde
2
2
  type __VLS_Props = {
3
3
  showAdd?: boolean;
4
4
  showClear?: boolean;
5
- /** `sheet` collapses the tags into a bottom sheet on mobile; `tags` keeps inline tags everywhere. */
5
+ /** `tags` keeps inline tags on mobile (default); `sheet` moves all filters into a bottom sheet. */
6
6
  mobile?: 'sheet' | 'tags';
7
7
  size?: DataListControlSize;
8
8
  ui?: DataListFilterTagsUi;
@@ -5,6 +5,7 @@ import USlideover from "@nuxt/ui/components/Slideover.vue";
5
5
  import { computed, onMounted, watch } from "vue";
6
6
  import { useUiToolsLocale } from "#ui-tools/i18n";
7
7
  import { isNullish } from "../../../../shared/utils/predicate";
8
+ import { useDataListBreakpoint } from "../../../composables/use-data-list-breakpoint";
8
9
  import { useDataListUi } from "../../../composables/use-data-list-ui";
9
10
  import { useTableInternals } from "../../../composables/use-table-internals";
10
11
  import { mergeDataListProps, mergeDataListUiClass } from "../../../utils";
@@ -19,6 +20,7 @@ const props = defineProps({
19
20
  });
20
21
  const internals = useTableInternals();
21
22
  const dataListUi = useDataListUi();
23
+ const { isMobile } = useDataListBreakpoint();
22
24
  const { locale, t } = useUiToolsLocale();
23
25
  const config = computed(() => dataListUi.ui.value.filterPanel);
24
26
  const resolvedUi = computed(() => ({ ...config.value?.ui, ...props.ui }));
@@ -39,6 +41,7 @@ const triggerBind = computed(() => {
39
41
  return rest;
40
42
  });
41
43
  const triggerLabel = computed(() => triggerProps.value.label ?? t("table.filters.panel.trigger"));
44
+ const hasTriggerIcon = computed(() => Boolean(triggerProps.value.icon));
42
45
  const countProps = computed(
43
46
  () => controlProps.value.count === false ? null : mergeDataListProps(
44
47
  { color: "neutral", size: "xs", variant: "solid" },
@@ -212,6 +215,10 @@ onMounted(() => {
212
215
  <UButton
213
216
  v-bind="triggerBind"
214
217
  :aria-expanded="open"
218
+ :aria-label="triggerLabel"
219
+ :square="
220
+ triggerProps.square ?? (isMobile && hasTriggerIcon && (!countProps || activeCount === 0))
221
+ "
215
222
  :ui="{
216
223
  base: mergeDataListUiClass(
217
224
  'nut-dl-fpanel-trigger shrink-0',
@@ -221,11 +228,16 @@ onMounted(() => {
221
228
  }"
222
229
  >
223
230
  <span
231
+ v-if="!isMobile || !hasTriggerIcon || countProps && activeCount > 0"
224
232
  :class="
225
- mergeDataListUiClass('flex items-center gap-2', void 0, resolvedUi.triggerContent)
233
+ mergeDataListUiClass(
234
+ 'nut-dl-fpanel-trigger__content flex items-center gap-2',
235
+ void 0,
236
+ resolvedUi.triggerContent
237
+ )
226
238
  "
227
239
  >
228
- <span>{{ triggerLabel }}</span>
240
+ <span v-if="!isMobile || !hasTriggerIcon">{{ triggerLabel }}</span>
229
241
  <UBadge
230
242
  v-if="countProps && activeCount > 0"
231
243
  v-bind="countProps"
@@ -48,11 +48,12 @@ const geometry = computed(() => resolveDataListControlGeometry(size.value));
48
48
  :key="entry.value == null ? entry.label : String(entry.value)"
49
49
  :class="
50
50
  mergeDataListUiClass(
51
- `nut-dl-option flex items-center rounded-md text-left outline-none transition-colors hover:bg-elevated ${sizeClasses.option} ${entry.selected ? 'text-highlighted' : 'text-default'}`,
51
+ `nut-dl-option flex cursor-pointer items-center rounded-md text-left outline-none transition-colors hover:bg-elevated ${sizeClasses.option} ${entry.selected ? 'text-highlighted' : 'text-default'}`,
52
52
  void 0,
53
53
  ui?.option
54
54
  )
55
55
  "
56
+ @click="emit('select', { event: $event, entry, index, sectionKey: section.key })"
56
57
  >
57
58
  <UCheckbox
58
59
  :model-value="entry.selected ?? false"
@@ -67,7 +68,6 @@ const geometry = computed(() => resolveDataListControlGeometry(size.value));
67
68
  <button
68
69
  type="button"
69
70
  :class="['flex min-w-0 flex-1 items-center text-left', geometry.toolbarGap]"
70
- @click="emit('select', { event: $event, entry, index, sectionKey: section.key })"
71
71
  >
72
72
  <span
73
73
  v-if="entry.color"
@@ -39,7 +39,7 @@ const modelValue = defineModel({ type: null, ...{
39
39
  root: 'w-full',
40
40
  fieldset: mergeDataListUiClass('grid gap-0.5', void 0, ui?.list),
41
41
  item: mergeDataListUiClass(
42
- `flex items-center rounded-md transition-colors hover:bg-elevated data-[state=checked]:bg-elevated ${sizeClasses.option}`,
42
+ `relative flex cursor-pointer items-center rounded-md transition-colors hover:bg-elevated data-[state=checked]:bg-elevated ${sizeClasses.option}`,
43
43
  void 0,
44
44
  ui?.option
45
45
  ),
@@ -47,7 +47,7 @@ const modelValue = defineModel({ type: null, ...{
47
47
  base: 'cursor-pointer',
48
48
  wrapper: 'min-w-0 flex-1',
49
49
  label: mergeDataListUiClass(
50
- `w-full cursor-pointer text-default ${sizeClasses.optionLabel}`,
50
+ `w-full cursor-pointer text-default before:absolute before:inset-0 ${sizeClasses.optionLabel}`,
51
51
  void 0,
52
52
  ui?.optionLabel
53
53
  )
@@ -61,12 +61,13 @@ function activateTreeEntry(entry) {
61
61
  :key="entry.id"
62
62
  :class="
63
63
  mergeDataListUiClass(
64
- `flex items-center rounded-md text-left transition-colors hover:bg-elevated ${sizeClasses.option} ${entry.selected ? 'bg-elevated text-highlighted' : 'text-default'}`,
64
+ `flex cursor-pointer items-center rounded-md text-left transition-colors hover:bg-elevated ${sizeClasses.option} ${entry.selected ? 'bg-elevated text-highlighted' : 'text-default'}`,
65
65
  void 0,
66
66
  ui?.option
67
67
  )
68
68
  "
69
69
  :style="getTreeIndentStyle(entry.depth)"
70
+ @click="activateTreeEntry(entry)"
70
71
  >
71
72
  <button
72
73
  v-if="entry.expandable"
@@ -111,7 +112,6 @@ function activateTreeEntry(entry) {
111
112
  type="button"
112
113
  class="flex min-w-0 flex-1 items-center text-left"
113
114
  :class="geometry.toolbarGap"
114
- @click="activateTreeEntry(entry)"
115
115
  >
116
116
  <UIcon
117
117
  v-if="entry.icon"
@@ -161,7 +161,7 @@ function activateTreeEntry(entry) {
161
161
  root: 'w-full',
162
162
  fieldset: mergeDataListUiClass('grid gap-0.5', void 0, ui?.list),
163
163
  item: mergeDataListUiClass(
164
- `flex items-center rounded-md transition-colors hover:bg-elevated data-[state=checked]:bg-elevated ${sizeClasses.option}`,
164
+ `relative flex cursor-pointer items-center rounded-md transition-colors hover:bg-elevated data-[state=checked]:bg-elevated ${sizeClasses.option}`,
165
165
  void 0,
166
166
  ui?.option
167
167
  ),
@@ -169,7 +169,7 @@ function activateTreeEntry(entry) {
169
169
  base: 'cursor-pointer',
170
170
  wrapper: 'min-w-0 flex-1',
171
171
  label: mergeDataListUiClass(
172
- `w-full cursor-pointer text-default ${sizeClasses.optionLabel}`,
172
+ `w-full cursor-pointer text-default before:absolute before:inset-0 ${sizeClasses.optionLabel}`,
173
173
  void 0,
174
174
  ui?.optionLabel
175
175
  )
@@ -187,7 +187,7 @@ function activateTreeEntry(entry) {
187
187
  :aria-label="item.expanded ? `Collapse ${item.label}` : `Expand ${item.label}`"
188
188
  :aria-expanded="item.expanded"
189
189
  :class="[
190
- 'flex shrink-0 items-center justify-center rounded-sm text-muted transition-transform hover:bg-accented',
190
+ 'relative z-10 flex shrink-0 items-center justify-center rounded-sm text-muted transition-transform hover:bg-accented',
191
191
  sizeClasses.optionIcon,
192
192
  item.expanded ? 'rotate-90' : ''
193
193
  ]"
@@ -68,7 +68,13 @@ const labelClass = computed(() => `flex min-w-0 items-center ${geometry.value.to
68
68
 
69
69
  <template>
70
70
  <div
71
- :class="mergeDataListUiClass('inline-flex min-w-0 max-w-full align-top', void 0, ui?.root)"
71
+ :class="
72
+ mergeDataListUiClass(
73
+ 'nut-dl-tag-wrap inline-flex min-w-0 max-w-full align-top',
74
+ void 0,
75
+ ui?.root
76
+ )
77
+ "
72
78
  >
73
79
  <template v-if="showOperatorPickerFirst">
74
80
  <UButton
@@ -1,4 +1,4 @@
1
- import { useQuery, useQueryClient } from "@tanstack/vue-query";
1
+ import { hashKey, useQuery, useQueryClient } from "@tanstack/vue-query";
2
2
  import { computed, shallowRef, watch } from "vue";
3
3
  import { isBoolean, isNullish, isNumber, isString } from "../../shared/utils/predicate.js";
4
4
  import { QUERY_DEFAULTS } from "../constants/query-state.js";
@@ -11,6 +11,21 @@ export function useTableFilterSelectedOptions(params) {
11
11
  return remote ? [{ key: definition.key, remote }] : [];
12
12
  })
13
13
  );
14
+ const selectedScopes = computed(
15
+ () => remoteDefinitions.value.map(({ key, remote }) => ({
16
+ identity: remote.selectedQueryKeyFor ? hashKey(remote.selectedQueryKeyFor({ values: [] })) : null,
17
+ key
18
+ }))
19
+ );
20
+ watch(selectedScopes, (current, previous) => {
21
+ const earlier = new Map(previous.map((entry) => [entry.key, entry.identity]));
22
+ const next = new Map(known.value);
23
+ for (const entry of current) {
24
+ if (earlier.has(entry.key) && earlier.get(entry.key) !== entry.identity)
25
+ next.delete(entry.key);
26
+ }
27
+ if (next.size !== known.value.size) known.value = next;
28
+ });
14
29
  function selectedValues(key) {
15
30
  const rule = params.rules.value.find((entry) => entry.key === key);
16
31
  const values = Array.isArray(rule?.value) ? rule.value : isNullish(rule?.value) ? [] : [rule.value];
@@ -1,3 +1,4 @@
1
+ import type { QueryKey } from '@tanstack/vue-query';
1
2
  import type { RemoteOptionsPageRequest, RemoteOptionsPagination, RemoteOptionsResult, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
2
3
  import type { TableFilterOperator, TableQueryStateFilterRule, TableQueryStateFilterValue } from './query-state.js';
3
4
  import type { TableFacetExecutionResult, TableFacetRequestDescriptor, TableFacetsContext, TableQueryDefinition } from './source.js';
@@ -566,6 +567,12 @@ export interface TableFilterRemoteOptions<TValue = TableFilterPrimitiveValue, TP
566
567
  resolveSelected?: {
567
568
  bivarianceHack(request: TableFilterRemoteSelectedRequest<TValue>): TableQueryDefinition<readonly TableOptionEntryForPresentation<TValue, TPresentation>[]>;
568
569
  }['bivarianceHack'];
570
+ /** Identity of a selected lookup for reusable loaders with changing external scope. */
571
+ selectedQueryKeyFor?: {
572
+ bivarianceHack(request: {
573
+ values: readonly TValue[];
574
+ }): QueryKey;
575
+ }['bivarianceHack'];
569
576
  /**
570
577
  * Page size, and how far before the list end the next page loads. Defaults to
571
578
  * `{ type: 'page', size: 25 }` with the next page requested three viewport heights ahead.
@@ -1,6 +1,5 @@
1
1
  import type { QueryKey } from '@tanstack/vue-query';
2
- import type { QueryFnDefinition } from '../../shared/types/query.js';
3
- import type { RemoteOptionsPageRequest, RemoteOptionsPagination, RemoteOptionsResult, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
2
+ import type { RemoteOptionsLoader, RemoteOptionsPagination, RemoteOptionsSearch } from '../../shared/types/remote-options.js';
4
3
  import type { TableResolvedFilterGroup } from './filters.js';
5
4
  import type { TableCursorPageResult, TableOffsetPageResult, TableRemoteSourceRequest, TableSourceExecutionResult, TableSourceQueryResult, TableSourceRow } from './source.js';
6
5
  import type { GenericObject, TableKnownFieldPath, TableSortingRule, TableSortKey } from './utils.js';
@@ -47,18 +46,6 @@ export interface RemoteTableOptionsConfig<TRow extends GenericObject, TOption ex
47
46
  }
48
47
  /**
49
48
  * Remote option source built by `remoteTableOptions()`. It fits dashboard `p.remote()`, table
50
- * option filters (`source.remote`), and form remote options (`source: options.load`).
49
+ * option filters (`source.remote`), and form remote options (`loader: options`).
51
50
  */
52
- export interface RemoteTableOptions<TOption> {
53
- /** Query definition of one page of options for a search term. */
54
- load: (request: {
55
- search: string;
56
- page: RemoteOptionsPageRequest;
57
- }) => QueryFnDefinition<RemoteOptionsResult<TOption>>;
58
- /** Query definition of the options of selected values. */
59
- resolveSelected: (request: {
60
- values: readonly (string | number | boolean)[];
61
- }) => QueryFnDefinition<readonly TOption[]>;
62
- pagination: RemoteOptionsPagination;
63
- search?: RemoteOptionsSearch;
64
- }
51
+ export type RemoteTableOptions<TOption> = RemoteOptionsLoader<TOption>;
@@ -1,4 +1,5 @@
1
- import { isArray, isFunction, isObject } from "../../shared/utils/predicate.js";
1
+ import { isArray, isObject } from "../../shared/utils/predicate.js";
2
+ import { mapRemoteOptionsQuery } from "../../shared/utils/remote-options-query.js";
2
3
  const DEFAULT_PAGINATION = { size: 25, type: "cursor" };
3
4
  export function remoteTableOptions(query, config) {
4
5
  const pagination = config.pagination ?? DEFAULT_PAGINATION;
@@ -13,55 +14,54 @@ export function remoteTableOptions(query, config) {
13
14
  sorting: params.filters ? [] : [...sorting]
14
15
  };
15
16
  }
17
+ function selectedRequest(values) {
18
+ return request({
19
+ filters: [
20
+ {
21
+ children: [
22
+ {
23
+ key: config.valueKey ?? "id",
24
+ operator: "isAnyOf",
25
+ type: "condition",
26
+ value: [...values]
27
+ }
28
+ ],
29
+ combinator: "and",
30
+ type: "group"
31
+ }
32
+ ],
33
+ page: { cursor: null, index: 1, size: Math.max(1, values.length) },
34
+ search: ""
35
+ });
36
+ }
16
37
  return {
17
38
  load: ({ page, search: term }) => mapQuery(query(request({ page, search: term })), (result) => ({
18
39
  ...resolvePage(result),
19
40
  options: result.rows.map(config.option)
20
41
  })),
21
42
  pagination,
22
- resolveSelected: ({ values }) => mapQuery(
23
- query(
24
- request({
25
- filters: [
26
- {
27
- children: [
28
- {
29
- key: config.valueKey ?? "id",
30
- operator: "isAnyOf",
31
- type: "condition",
32
- value: [...values]
33
- }
34
- ],
35
- combinator: "and",
36
- type: "group"
37
- }
38
- ],
39
- page: { cursor: null, index: 1, size: Math.max(1, values.length) },
40
- search: ""
41
- })
42
- ),
43
- (result) => result.rows.map(config.option)
44
- ),
45
- search
43
+ queryKeyFor: ({ page, search: term }) => [
44
+ ...query(request({ page, search: term })).queryKey,
45
+ "remote-options"
46
+ ],
47
+ resolveSelected: ({ values }) => mapQuery(query(selectedRequest(values)), (result) => result.rows.map(config.option)),
48
+ search,
49
+ selectedQueryKeyFor: ({ values }) => [
50
+ ...query(selectedRequest(values)).queryKey,
51
+ "remote-options"
52
+ ]
46
53
  };
47
54
  }
48
55
  function mapQuery(definition, map) {
49
- const { queryKey } = definition;
50
- const queryFn = "queryFn" in definition ? definition.queryFn : void 0;
51
- return {
52
- async queryFn(context) {
53
- if (!isQueryFn(queryFn))
54
- throw new Error("[remoteTableOptions] `query` must return a query with a queryFn.");
55
- const result = await queryFn({ ...context, queryKey });
56
+ return mapRemoteOptionsQuery(
57
+ definition,
58
+ (result) => {
56
59
  if (!isTableResult(result))
57
60
  throw new Error("[remoteTableOptions] the query must resolve to a table response (`rows`).");
58
61
  return map(result);
59
62
  },
60
- queryKey: [...queryKey, "remote-options"]
61
- };
62
- }
63
- function isQueryFn(value) {
64
- return isFunction(value);
63
+ ["remote-options"]
64
+ );
65
65
  }
66
66
  function isTableResult(value) {
67
67
  return isObject(value) && isArray(value.rows);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-ui-tools",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Nuxt module and runtime toolkit for UI-heavy Nuxt applications",
5
5
  "homepage": "https://github.com/ChronicStone/nuxt-ui-tools#readme",
6
6
  "bugs": "https://github.com/ChronicStone/nuxt-ui-tools/issues",