nuxt-ui-tools 1.25.1 → 1.27.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 (49) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/file-preview/components/shell/file-preview-header.d.vue.ts +2 -0
  3. package/dist/runtime/file-preview/components/shell/file-preview-header.vue +13 -7
  4. package/dist/runtime/file-preview/components/shell/file-preview-header.vue.d.ts +2 -0
  5. package/dist/runtime/file-preview/components/shell/file-preview-shell.vue +6 -0
  6. package/dist/runtime/file-preview/components/shell/file-preview-stage.d.vue.ts +5 -3
  7. package/dist/runtime/file-preview/components/shell/file-preview-stage.vue +4 -10
  8. package/dist/runtime/file-preview/components/shell/file-preview-stage.vue.d.ts +5 -3
  9. package/dist/runtime/form/components/root/form.vue +1 -0
  10. package/dist/runtime/form/composables/use-field-options.js +3 -1
  11. package/dist/runtime/form/composables/use-form-overlay-controller.js +1 -1
  12. package/dist/runtime/form/composables/use-form-root.d.ts +3 -1
  13. package/dist/runtime/form/composables/use-form-root.js +26 -11
  14. package/dist/runtime/form/composables/use-form-runtime.js +14 -4
  15. package/dist/runtime/form/composables/use-form-validation.d.ts +5 -2
  16. package/dist/runtime/form/composables/use-form-validation.js +51 -5
  17. package/dist/runtime/form/fields/custom-component/component.vue +2 -3
  18. package/dist/runtime/form/fields/date-family/component.vue +7 -6
  19. package/dist/runtime/form/fields/date-family/utils.d.ts +5 -0
  20. package/dist/runtime/form/fields/date-family/utils.js +13 -0
  21. package/dist/runtime/form/fields/number/component.vue +11 -5
  22. package/dist/runtime/form/fields/phone-number/component.vue +20 -1
  23. package/dist/runtime/form/types/api.d.ts +9 -2
  24. package/dist/runtime/form/types/options.d.ts +4 -1
  25. package/dist/runtime/form/types/runtime.d.ts +10 -0
  26. package/dist/runtime/form/types/schema.d.ts +5 -1
  27. package/dist/runtime/form/utils/api.js +1 -0
  28. package/dist/runtime/i18n/locales/en.js +12 -3
  29. package/dist/runtime/i18n/locales/fr.js +12 -3
  30. package/dist/runtime/i18n/types.d.ts +16 -0
  31. package/dist/runtime/table/components/filters/tags/date-filter-tag.vue +4 -0
  32. package/dist/runtime/table/composables/use-filter-persistence.d.ts +14 -0
  33. package/dist/runtime/table/composables/use-filter-persistence.js +77 -0
  34. package/dist/runtime/table/composables/use-query-state.js +31 -26
  35. package/dist/runtime/table/composables/use-table-api.js +4 -0
  36. package/dist/runtime/table/composables/use-table-data.js +3 -2
  37. package/dist/runtime/table/composables/use-table-state.d.ts +1 -0
  38. package/dist/runtime/table/composables/use-table-state.js +13 -2
  39. package/dist/runtime/table/types/filters.d.ts +28 -0
  40. package/dist/runtime/table/types/table-api.d.ts +5 -0
  41. package/dist/runtime/table/utils/filter-persistence.d.ts +16 -0
  42. package/dist/runtime/table/utils/filter-persistence.js +42 -0
  43. package/dist/runtime/table/utils/filters/options.js +2 -1
  44. package/dist/runtime/table/utils/filters/preview/option-preview.js +4 -1
  45. package/dist/runtime/table/utils/index.d.ts +1 -0
  46. package/dist/runtime/table/utils/index.js +1 -0
  47. package/dist/runtime/table/utils/resolved-filters.d.ts +8 -0
  48. package/dist/runtime/table/utils/resolved-filters.js +6 -0
  49. package/package.json +1 -1
@@ -193,6 +193,19 @@ export function calendarSeedFromValue(value, type) {
193
193
  }
194
194
  return "";
195
195
  }
196
+ export function formatDateFamilyBound(value, locale) {
197
+ const date = /^\d{4}$/u.test(value) ? void 0 : canonicalDateToJsDate(value);
198
+ if (!date) {
199
+ return value;
200
+ }
201
+ if (/^\d{4}-\d{2}$/u.test(value)) {
202
+ return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
203
+ }
204
+ return new Intl.DateTimeFormat(
205
+ locale,
206
+ value.includes("T") ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }
207
+ ).format(date);
208
+ }
196
209
  export function calendarDateToJsDate(value) {
197
210
  return value.toDate(getLocalTimeZone());
198
211
  }
@@ -16,7 +16,7 @@ const { code } = useUiToolsLocale();
16
16
  const { fieldProps, form, controlProps, disabled, handleBlur, placeholder } = useFieldControl(
17
17
  () => props.field,
18
18
  () => props.path,
19
- { omit: ["prefix", "suffix", "format", "controls", "mono"] }
19
+ { omit: ["prefix", "suffix", "format", "controls", "mono", "min", "max"] }
20
20
  );
21
21
  const model = computed({
22
22
  get: () => {
@@ -25,6 +25,12 @@ const model = computed({
25
25
  },
26
26
  set: (value) => form.setValue(props.path, value ?? null)
27
27
  });
28
+ const atMax = computed(
29
+ () => isNumber(model.value) && isNumber(fieldProps.value.max) && model.value >= fieldProps.value.max
30
+ );
31
+ const atMin = computed(
32
+ () => isNumber(model.value) && isNumber(fieldProps.value.min) && model.value <= fieldProps.value.min
33
+ );
28
34
  const prefix = computed(() => resolveFormText(fieldProps.value.prefix));
29
35
  const suffix = computed(() => resolveFormText(fieldProps.value.suffix));
30
36
  const controls = computed(() => fieldProps.value.controls !== false);
@@ -76,13 +82,13 @@ const affixStyle = computed(() => ({
76
82
  :ui="controlUi"
77
83
  :placeholder="placeholder"
78
84
  :disabled="disabled"
79
- :min="fieldProps.min"
80
- :max="fieldProps.max"
81
85
  :step="fieldProps.step"
82
86
  :locale="code"
83
87
  :format-options="fieldProps.format"
84
88
  :increment="controls"
85
89
  :decrement="controls"
90
+ :increment-disabled="atMax"
91
+ :decrement-disabled="atMin"
86
92
  @blur="handleBlur"
87
93
  />
88
94
  <span
@@ -121,13 +127,13 @@ const affixStyle = computed(() => ({
121
127
  :ui="controlUi"
122
128
  :placeholder="placeholder"
123
129
  :disabled="disabled"
124
- :min="fieldProps.min"
125
- :max="fieldProps.max"
126
130
  :step="fieldProps.step"
127
131
  :locale="code"
128
132
  :format-options="fieldProps.format"
129
133
  :increment="controls"
130
134
  :decrement="controls"
135
+ :increment-disabled="atMax"
136
+ :decrement-disabled="atMin"
131
137
  @blur="handleBlur"
132
138
  />
133
139
  <span
@@ -4,7 +4,12 @@ import UFieldGroup from "@nuxt/ui/components/FieldGroup.vue";
4
4
  import UIcon from "@nuxt/ui/components/Icon.vue";
5
5
  import UInput from "@nuxt/ui/components/Input.vue";
6
6
  import USelectMenu from "@nuxt/ui/components/SelectMenu.vue";
7
- import { getCountries, getCountryCallingCode, parsePhoneNumberFromString } from "libphonenumber-js";
7
+ import {
8
+ getCountries,
9
+ getCountryCallingCode,
10
+ parsePhoneNumberFromString,
11
+ validatePhoneNumberLength
12
+ } from "libphonenumber-js";
8
13
  import { computed, onMounted, ref, useId, watch } from "vue";
9
14
  import { useUiToolsLocale } from "../../../i18n/use-locale";
10
15
  import FormFieldShell from "../../components/renderer/form-field-shell.vue";
@@ -47,6 +52,20 @@ const processedValue = computed(() => {
47
52
  }
48
53
  return { valid: true, value: formatPhoneNumber(parsed) };
49
54
  });
55
+ const inputError = computed(() => {
56
+ if (!phoneValue.value || !countryCode.value || processedValue.value.valid) {
57
+ return null;
58
+ }
59
+ return validatePhoneNumberLength(phoneValue.value, countryCode.value) === "TOO_SHORT" ? t("form.validation.phoneIncomplete") : t("form.validation.phoneInvalid");
60
+ });
61
+ watch(
62
+ [() => props.path, inputError],
63
+ ([path, message], _previous, onCleanup) => {
64
+ form.setInputError(path, message);
65
+ onCleanup(() => form.setInputError(path, null));
66
+ },
67
+ { immediate: true }
68
+ );
50
69
  onMounted(() => {
51
70
  syncFromExternalValue(form.getValue(props.path));
52
71
  if (!countryCode.value) {
@@ -61,8 +61,8 @@ export interface FormFieldFormApi {
61
61
  submit: () => Promise<boolean>;
62
62
  /**
63
63
  * Closes the form the way its Cancel action does: asks to discard unsaved changes when the
64
- * schema asks for it, then cancels (an overlay closes). Does nothing for a form rendered without
65
- * a cancel handler.
64
+ * schema asks for it (an overlay form does by default), then cancels (an overlay closes). Does
65
+ * nothing for a form rendered without a cancel handler.
66
66
  */
67
67
  close: () => Promise<void>;
68
68
  /** Advances to the next step on stepped forms. */
@@ -206,6 +206,13 @@ export interface FormApi<TOutput = FormObject> {
206
206
  submit: () => Promise<void>;
207
207
  /** Resets the form and waits for dependency effects triggered by the reset. */
208
208
  reset: () => Promise<void>;
209
+ /**
210
+ * Closes the form the way its Cancel action does: asks to discard unsaved changes when the
211
+ * schema asks for it (an overlay form does by default), then cancels, so an overlay closes and
212
+ * its `createForm` promise resolves with `isCompleted: false`. Does nothing for a form rendered
213
+ * without a cancel handler.
214
+ */
215
+ close: () => Promise<void>;
209
216
  }
210
217
  export type FormSubmitAction = 'next' | 'previous' | 'submit' | 'reset';
211
218
  export type FormSubmitResult<TSubmitData = FormValue> = boolean | void | {
@@ -257,7 +257,10 @@ export interface FormRemoteOptionConfig<TOption, TContext = NonNullable<unknown>
257
257
  create?: FormCreateOption<TOption, TContext, TDeps, TValue>;
258
258
  /** Enables the field-level refresh affordance. */
259
259
  allowOptionsRefresh?: boolean;
260
- /** Disable the field while its option source has no usable data yet. */
260
+ /**
261
+ * Ignored: remote options load once the menu opens and the menu shows the loading state, so the
262
+ * field and its search input stay usable while a page loads.
263
+ */
261
264
  disableOnLoading?: boolean;
262
265
  }
263
266
  /** Reuses one remote loader while keeping field-specific refresh and creation behavior local. */
@@ -80,6 +80,11 @@ export interface FormRuntime {
80
80
  validate: (options?: FormValidationOptions) => Promise<boolean>;
81
81
  validateCurrentStep: (options?: FormValidationOptions) => Promise<boolean>;
82
82
  setError: (path: string | readonly string[], message: string, options?: FormErrorOptions) => void;
83
+ /**
84
+ * Reports input a control holds but cannot store as a value, such as an incomplete phone number,
85
+ * or clears it with `null`. It shows like a rule error once the field is touched and blocks submit.
86
+ */
87
+ setInputError: (path: string | readonly string[], message: string | null) => void;
83
88
  clearError: (path?: string | readonly string[]) => void;
84
89
  focusRequest: Ref<FormFocusRequest | null>;
85
90
  registerFieldElement: (path: string | readonly string[], element: HTMLElement) => () => void;
@@ -94,4 +99,9 @@ export interface FormRuntime {
94
99
  goToStep: (index: number) => Promise<boolean>;
95
100
  /** Cancels the rendered form, as its Cancel action does. Set by the form root that renders it. */
96
101
  bindCancel: (cancel: (() => Promise<void>) | null) => void;
102
+ /**
103
+ * Closes the form the way its Cancel action does, asking first when it holds unsaved changes the
104
+ * schema protects. Resolves at once when no form root renders the runtime.
105
+ */
106
+ close: () => Promise<void>;
97
107
  }
@@ -18,7 +18,11 @@ export interface FormControlsConfig {
18
18
  ignoreDirtyPaths?: readonly string[];
19
19
  /** Focuses the first field after mount, or a specific raw field path. */
20
20
  autoFocus?: boolean | string;
21
- /** Warns before an inline dirty form is closed or navigated away from. */
21
+ /**
22
+ * Asks before a modified form is cancelled, closed, or left for another page. A form opened as a
23
+ * modal, drawer, or fullscreen overlay asks before closing (Escape, outside click, close button,
24
+ * Cancel, `api.close()`) unless this is `false`; leaving the page asks only when it is set.
25
+ */
22
26
  confirmNavOnDirty?: boolean | FormDirtyNavigationConfig;
23
27
  /** Keeps external input changes synchronized after the initial mount. */
24
28
  syncInput?: boolean | readonly string[];
@@ -2,6 +2,7 @@ import { pathSegments } from "./path.js";
2
2
  export function createPublicFormApi(runtime) {
3
3
  return {
4
4
  clearError: (path) => runtime.clearError(path ? pathSegments(path) : void 0),
5
+ close: runtime.close,
5
6
  focus: runtime.focusField,
6
7
  get: runtime.getValue,
7
8
  initial: runtime.getInitialValue,
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
269
269
  open: "Open color picker"
270
270
  },
271
271
  date: {
272
+ calendar: "Calendar",
272
273
  clear: "Clear value",
273
274
  confirm: "Confirm",
274
275
  end: "End",
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
355
356
  fallback: "Step {number}"
356
357
  },
357
358
  validation: {
358
- dateMax: "The date must be on or before {max}",
359
- dateMin: "The date must be on or after {min}",
360
- required: "This field is required"
359
+ dateMax: "Date after {max}",
360
+ dateMin: "Date before {min}",
361
+ monthMax: "Month after {max}",
362
+ monthMin: "Month before {min}",
363
+ numberMax: "Maximum value: {bound}",
364
+ numberMin: "Minimum value: {bound}",
365
+ phoneIncomplete: "Incomplete phone number",
366
+ phoneInvalid: "Invalid phone number",
367
+ required: "This field is required",
368
+ yearMax: "Year after {max}",
369
+ yearMin: "Year before {min}"
361
370
  }
362
371
  },
363
372
  spreadsheet: {
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
269
269
  open: "Ouvrir le s\xE9lecteur de couleur"
270
270
  },
271
271
  date: {
272
+ calendar: "Calendrier",
272
273
  clear: "Effacer la valeur",
273
274
  confirm: "Confirmer",
274
275
  end: "Fin",
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
355
356
  fallback: "\xC9tape {number}"
356
357
  },
357
358
  validation: {
358
- dateMax: "La date doit \xEAtre au plus tard le {max}",
359
- dateMin: "La date doit \xEAtre au plus t\xF4t le {min}",
360
- required: "Ce champ est requis"
359
+ dateMax: "Date post\xE9rieure au {max}",
360
+ dateMin: "Date ant\xE9rieure au {min}",
361
+ monthMax: "Mois post\xE9rieur \xE0 {max}",
362
+ monthMin: "Mois ant\xE9rieur \xE0 {min}",
363
+ numberMax: "Valeur maximale : {bound}",
364
+ numberMin: "Valeur minimale : {bound}",
365
+ phoneIncomplete: "Num\xE9ro de t\xE9l\xE9phone incomplet",
366
+ phoneInvalid: "Num\xE9ro de t\xE9l\xE9phone invalide",
367
+ required: "Ce champ est requis",
368
+ yearMax: "Ann\xE9e post\xE9rieure \xE0 {max}",
369
+ yearMin: "Ann\xE9e ant\xE9rieure \xE0 {min}"
361
370
  }
362
371
  },
363
372
  spreadsheet: {
@@ -504,8 +504,22 @@ export interface UiToolsFormMessages {
504
504
  };
505
505
  validation: {
506
506
  required: string;
507
+ /** A date before the field's `min`: "Date before {min}". */
507
508
  dateMin: string;
509
+ /** A date after the field's `max`: "Date after {max}". */
508
510
  dateMax: string;
511
+ monthMin: string;
512
+ monthMax: string;
513
+ yearMin: string;
514
+ yearMax: string;
515
+ /** A number below the field's `min`: "Minimum value: {bound}". */
516
+ numberMin: string;
517
+ /** A number above the field's `max`: "Maximum value: {bound}". */
518
+ numberMax: string;
519
+ /** A phone number too short for its country. */
520
+ phoneIncomplete: string;
521
+ /** A phone number that is not valid for its country or allowed types. */
522
+ phoneInvalid: string;
509
523
  };
510
524
  fields: {
511
525
  text: {
@@ -562,6 +576,8 @@ export interface UiToolsFormMessages {
562
576
  end: string;
563
577
  clear: string;
564
578
  confirm: string;
579
+ /** Accessible name of the calendar when the field has no label, read before the month. */
580
+ calendar: string;
565
581
  tokens: {
566
582
  day: string;
567
583
  month: string;
@@ -464,6 +464,9 @@ function areSameCalendarDay(left, right) {
464
464
  <UCalendar
465
465
  :model-value="calendarRange"
466
466
  :size="size"
467
+ :calendar-label="
468
+ internals.filters.getFilterLabelText({ label: definition.label })
469
+ "
467
470
  range
468
471
  :number-of-months="rangeCalendarPanels"
469
472
  :paged-navigation="
@@ -602,6 +605,7 @@ function areSameCalendarDay(left, right) {
602
605
  "
603
606
  :model-value="localDate"
604
607
  :size="size"
608
+ :calendar-label="internals.filters.getFilterLabelText({ label: definition.label })"
605
609
  :fixed-weeks="filterUi.scalar.calendar.fixedWeeks"
606
610
  :ui="{
607
611
  root: mergeDataListUiClass(
@@ -0,0 +1,14 @@
1
+ import type { ComputedRef, WritableComputedRef } from 'vue';
2
+ import type { TableFilterState, TableSchemaView } from '../types/index.js';
3
+ export interface UseFilterPersistenceParams {
4
+ schema: ComputedRef<TableSchemaView>;
5
+ filters: WritableComputedRef<TableFilterState>;
6
+ /** Writes the restored state without adding a browser history entry. */
7
+ restore: (state: TableFilterState) => void;
8
+ }
9
+ /**
10
+ * Keeps the value of every filter marked `behavior.persist` in `localStorage`. On setup, a stored
11
+ * value replaces the default of a filter the URL does not set; afterwards each change is written
12
+ * back, and a filter back at its default is forgotten.
13
+ */
14
+ export declare function useFilterPersistence(params: UseFilterPersistenceParams): void;
@@ -0,0 +1,77 @@
1
+ import { watch } from "vue";
2
+ import {
3
+ isTableFilterRuleDefault,
4
+ parsePersistedFilterRule,
5
+ persistedFilterStorageKey,
6
+ serializePersistedFilterRule
7
+ } from "../utils/index.js";
8
+ export function useFilterPersistence(params) {
9
+ const storage = resolveStorage();
10
+ const persisted = (params.schema.value.filters?.ui ?? []).filter(
11
+ (definition) => definition.behavior?.persist === true
12
+ );
13
+ if (!storage || persisted.length === 0) {
14
+ return;
15
+ }
16
+ const storageKey = (definition) => persistedFilterStorageKey({
17
+ tableKey: params.schema.value.tableKey,
18
+ filterKey: definition.key
19
+ });
20
+ const current = params.filters.value.ui;
21
+ const restored = persisted.flatMap((definition) => {
22
+ const rule = current.find((candidate) => candidate.key === definition.key);
23
+ if (rule && !isTableFilterRuleDefault({ definition, rule })) {
24
+ return [];
25
+ }
26
+ const stored = parsePersistedFilterRule({
27
+ definition,
28
+ raw: safeRead(storage, storageKey(definition))
29
+ });
30
+ return stored ? [stored] : [];
31
+ });
32
+ if (restored.length > 0) {
33
+ const keys = new Set(restored.map((rule) => rule.key));
34
+ params.restore({
35
+ ...params.filters.value,
36
+ ui: [...current.filter((rule) => !keys.has(rule.key)), ...restored]
37
+ });
38
+ }
39
+ watch(
40
+ () => params.filters.value.ui,
41
+ (rules) => {
42
+ for (const definition of persisted) {
43
+ const rule = rules.find((candidate) => candidate.key === definition.key);
44
+ safeWrite(
45
+ storage,
46
+ storageKey(definition),
47
+ rule && !isTableFilterRuleDefault({ definition, rule }) ? serializePersistedFilterRule({ definition, rule }) : null
48
+ );
49
+ }
50
+ }
51
+ );
52
+ }
53
+ function resolveStorage() {
54
+ try {
55
+ return typeof window === "undefined" ? null : window.localStorage;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ function safeRead(storage, key) {
61
+ try {
62
+ return storage.getItem(key);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ function safeWrite(storage, key, value) {
68
+ try {
69
+ if (value === null) {
70
+ storage.removeItem(key);
71
+ } else {
72
+ storage.setItem(key, value);
73
+ }
74
+ } catch {
75
+ return;
76
+ }
77
+ }
@@ -19,6 +19,7 @@ import {
19
19
  resolveFilterSupportedOperators,
20
20
  serializeTableFilterQueryState
21
21
  } from "../utils/index.js";
22
+ import { useFilterPersistence } from "./use-filter-persistence.js";
22
23
  export function useQueryState(params) {
23
24
  const defaultPageSize = getDefaultPageSize({
24
25
  layout: params.activeLayout.value,
@@ -96,32 +97,36 @@ export function useQueryState(params) {
96
97
  sortingState.value = value ? { dir: value.dir, key: value.key } : { dir: void 0, key: "" };
97
98
  }
98
99
  });
99
- const filters = useQueryStates({
100
- historyMode: "push",
101
- prefix: "f",
102
- schema: {
103
- search: { codec: stringCodec, defaultValue: "" },
104
- ui: dynamicQueryState({
105
- defaultValue: resolveTableFilterDefaultRules(params.schema.value.filters?.ui ?? []),
106
- definitions: () => params.schema.value.filters?.ui ?? [],
107
- parse(entries, definitions) {
108
- return parseTableFilterQueryState({ definitions, entries });
109
- },
110
- resolve(filter) {
111
- const definition = normalizeFilterDefinition(filter);
112
- const operators = resolveFilterSupportedOperators(definition);
113
- const defaultOp = resolveFilterDefaultOperator(definition);
114
- const codec = createTableFilterValueCodec(definition);
115
- return operators.map((op) => ({
116
- codec,
117
- urlKey: op === defaultOp ? definition.key : `${definition.key}~${op}`
118
- }));
119
- },
120
- serialize(rules, definitions) {
121
- return serializeTableFilterQueryState({ definitions, rules });
122
- },
123
- urlPrefix: "ui"
124
- })
100
+ const filterSchema = {
101
+ search: { codec: stringCodec, defaultValue: "" },
102
+ ui: dynamicQueryState({
103
+ defaultValue: resolveTableFilterDefaultRules(params.schema.value.filters?.ui ?? []),
104
+ definitions: () => params.schema.value.filters?.ui ?? [],
105
+ parse(entries, definitions) {
106
+ return parseTableFilterQueryState({ definitions, entries });
107
+ },
108
+ resolve(filter) {
109
+ const definition = normalizeFilterDefinition(filter);
110
+ const operators = resolveFilterSupportedOperators(definition);
111
+ const defaultOp = resolveFilterDefaultOperator(definition);
112
+ const codec = createTableFilterValueCodec(definition);
113
+ return operators.map((op) => ({
114
+ codec,
115
+ urlKey: op === defaultOp ? definition.key : `${definition.key}~${op}`
116
+ }));
117
+ },
118
+ serialize(rules, definitions) {
119
+ return serializeTableFilterQueryState({ definitions, rules });
120
+ },
121
+ urlPrefix: "ui"
122
+ })
123
+ };
124
+ const filters = useQueryStates({ historyMode: "push", prefix: "f", schema: filterSchema });
125
+ useFilterPersistence({
126
+ schema: params.schema,
127
+ filters,
128
+ restore(state) {
129
+ useQueryStates({ historyMode: "replace", prefix: "f", schema: filterSchema }).value = state;
125
130
  }
126
131
  });
127
132
  return {
@@ -23,6 +23,10 @@ export function useTableApi(params) {
23
23
  // SAFETY: raw rows follow the same schema source contract before client-side shaping.
24
24
  rawRows: computed(() => params.queryContent.rawData.value.rows),
25
25
  rawRowCount: computed(() => params.queryContent.rawData.value.rowCount),
26
+ // SAFETY: filtered rows are the raw source rows after client filtering and sorting.
27
+ filteredRows: computed(
28
+ () => params.queryContent.filteredRows.value
29
+ ),
26
30
  context: computed(
27
31
  // SAFETY: context keys are decoded from the schema context items before publication.
28
32
  () => params.queryContent.contextData.value
@@ -59,6 +59,7 @@ export function useTableData(params) {
59
59
  () => params.state.queryState.sorting.value ? [params.state.queryState.sorting.value] : []
60
60
  );
61
61
  const requestFilters = computed(() => params.state.resolvedFilterState.value);
62
+ const rowFilters = computed(() => params.state.rowFilterState.value);
62
63
  const requestSearch = computed(() => ({
63
64
  fields: params.schema.value.filters?.search?.fields ?? [],
64
65
  value: params.state.queryState.filters.value.search
@@ -214,7 +215,7 @@ export function useTableData(params) {
214
215
  return rawData.value.rows;
215
216
  }
216
217
  return filterClientRows({
217
- filters: requestFilters.value,
218
+ filters: rowFilters.value,
218
219
  rows: rawData.value.rows,
219
220
  search: requestSearch.value
220
221
  });
@@ -269,7 +270,7 @@ export function useTableData(params) {
269
270
  if (params.schema.value.source.mode === "client") {
270
271
  return executeClientFacets({
271
272
  facets: clientFacetDescriptors.value,
272
- request: requestContext.value,
273
+ request: { ...requestContext.value, filters: rowFilters.value },
273
274
  rows: rawData.value.rows
274
275
  });
275
276
  }
@@ -49,4 +49,5 @@ export declare function useTableState(params: UseTableStateParams): {
49
49
  } | null>;
50
50
  };
51
51
  resolvedFilterState: ComputedRef<TableResolvedFilterGroup<string>>;
52
+ rowFilterState: ComputedRef<TableResolvedFilterGroup<string>>;
52
53
  };
@@ -1,5 +1,5 @@
1
1
  import { computed } from "vue";
2
- import { createResolvedFilterState } from "../utils/index.js";
2
+ import { createResolvedFilterState, withoutQueryFilterRules } from "../utils/index.js";
3
3
  import { useQueryState } from "./use-query-state.js";
4
4
  export function useTableState(params) {
5
5
  const queryState = useQueryState({
@@ -13,8 +13,19 @@ export function useTableState(params) {
13
13
  staticFilters: params.schema.value.filters?.static
14
14
  })
15
15
  );
16
+ const rowFilterState = computed(
17
+ () => createResolvedFilterState({
18
+ definitions: params.schema.value.filters?.ui ?? [],
19
+ filters: withoutQueryFilterRules({
20
+ definitions: params.schema.value.filters?.ui ?? [],
21
+ filters: queryState.filters.value
22
+ }),
23
+ staticFilters: params.schema.value.filters?.static
24
+ })
25
+ );
16
26
  return {
17
27
  queryState,
18
- resolvedFilterState
28
+ resolvedFilterState,
29
+ rowFilterState
19
30
  };
20
31
  }
@@ -31,6 +31,17 @@ export interface TableFilterBehaviorCommon<TOperator extends TableFilterOperator
31
31
  commitMode?: TableFilterCommitMode;
32
32
  clearOnOperatorChange?: boolean;
33
33
  reopenOnOperatorChange?: boolean;
34
+ /**
35
+ * What the filter narrows. `rows` (default) matches every row against it. `query` only hands the
36
+ * value to the source query, through its `filters`, so a client source can ask the server for a
37
+ * different dataset (a period, a scope) while its rows are never matched against the rule.
38
+ */
39
+ applyTo?: 'rows' | 'query';
40
+ /**
41
+ * Remembers the last value in `localStorage`, per table key and filter key. It is restored when
42
+ * the URL carries no value of its own, and forgotten once the filter returns to its default.
43
+ */
44
+ persist?: boolean;
34
45
  }
35
46
  export interface TableFilterUiActionLabelsConfig {
36
47
  actions?: {
@@ -647,6 +658,23 @@ export interface TableOptionFilterDefinition<TRow extends GenericObject = Generi
647
658
  */
648
659
  remote?: TableFilterRemoteOptions<TValue, TPresentation>;
649
660
  facet?: TableFilterFacetSpec<TRow, TContext, TKey>;
661
+ /**
662
+ * Labels the values a facet lists when `options` is not set, such as provider statuses stored
663
+ * as `Approved` or `Security compromised`. The facet still decides which values show, with their
664
+ * counts; a value it returns `undefined` for shows as stored. Tags and previews of a committed
665
+ * value read the same label.
666
+ *
667
+ * @example
668
+ * ```ts
669
+ * source: {
670
+ * facet: true,
671
+ * label: (value) => ({ Approved: 'Validé', Done: 'Terminé' })[String(value)],
672
+ * }
673
+ * ```
674
+ */
675
+ label?: {
676
+ bivarianceHack(value: TValue): TableTextValue | undefined;
677
+ }['bivarianceHack'];
650
678
  sort?: 'alpha' | 'count';
651
679
  };
652
680
  editor?: TableOptionFilterEditorConfig<TPresentation>;
@@ -87,6 +87,11 @@ export interface TableApi<TSchema = TableSchemaView> {
87
87
  totalRowCount: ComputedRef<number | null>;
88
88
  rawRows: ComputedRef<ExtractTableRow<TSchema>[]>;
89
89
  rawRowCount: ComputedRef<number | null>;
90
+ /**
91
+ * Every row that passes the filters and the search, sorted, before pagination. Client sources
92
+ * only; `undefined` for remote sources, which never hold the whole result.
93
+ */
94
+ filteredRows: ComputedRef<ExtractTableRow<TSchema>[] | undefined>;
90
95
  context: ComputedRef<ExtractTableContextData<TSchema>>;
91
96
  pageContext: ComputedRef<ExtractTablePageContextData<TSchema>>;
92
97
  requestContext: ReturnType<typeof useTableData>['requestContext'];
@@ -0,0 +1,16 @@
1
+ import type { TableQueryStateFilterRule, TableUiFilterDefinition } from '../types/index.js';
2
+ /** Storage key of one persisted filter, scoped by table key. */
3
+ export declare function persistedFilterStorageKey(options: {
4
+ tableKey: string;
5
+ filterKey: string;
6
+ }): string;
7
+ /** Serializes a rule with the filter's URL codec, or returns `null` when it has nothing to keep. */
8
+ export declare function serializePersistedFilterRule(options: {
9
+ definition: TableUiFilterDefinition;
10
+ rule: TableQueryStateFilterRule;
11
+ }): string | null;
12
+ /** Reads a stored rule back, dropping anything the filter no longer accepts. */
13
+ export declare function parsePersistedFilterRule(options: {
14
+ definition: TableUiFilterDefinition;
15
+ raw: string | null;
16
+ }): TableQueryStateFilterRule | null;
@@ -0,0 +1,42 @@
1
+ import { isObject, isString } from "../../shared/utils/predicate.js";
2
+ import {
3
+ createTableFilterValueCodec,
4
+ normalizeFilterDefinition,
5
+ resolveFilterDefaultOperator,
6
+ resolveFilterSupportedOperators
7
+ } from "./query-state.js";
8
+ const STORAGE_PREFIX = "nuxt-ui-tools:table-filter";
9
+ export function persistedFilterStorageKey(options) {
10
+ return `${STORAGE_PREFIX}:${options.tableKey}:${options.filterKey}`;
11
+ }
12
+ export function serializePersistedFilterRule(options) {
13
+ const normalized = normalizeFilterDefinition(options.definition);
14
+ const value = createTableFilterValueCodec(normalized).serialize(options.rule.value);
15
+ return value === null ? null : JSON.stringify({
16
+ operator: options.rule.operator ?? resolveFilterDefaultOperator(normalized),
17
+ value
18
+ });
19
+ }
20
+ export function parsePersistedFilterRule(options) {
21
+ if (!options.raw) {
22
+ return null;
23
+ }
24
+ const stored = parseStoredRule(options.raw);
25
+ if (!stored) {
26
+ return null;
27
+ }
28
+ const normalized = normalizeFilterDefinition(options.definition);
29
+ const operator = resolveFilterSupportedOperators(normalized).find(
30
+ (candidate) => candidate === stored.operator
31
+ );
32
+ const value = createTableFilterValueCodec(normalized).parse(stored.value);
33
+ return operator && value !== void 0 ? { key: options.definition.key, operator, value } : null;
34
+ }
35
+ function parseStoredRule(raw) {
36
+ try {
37
+ const stored = JSON.parse(raw);
38
+ return isObject(stored) && isString(stored.operator) && isString(stored.value) ? { operator: stored.operator, value: stored.value } : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }