@pienter/ui 0.5.0 → 0.7.1

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 (31) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/CONVENTIONS.md +45 -0
  3. package/README.md +30 -0
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/form/block-editor/BlockEditor.vue +454 -0
  8. package/components/form/block-editor/block-editor.css +149 -0
  9. package/components/form/block-editor/types.ts +15 -0
  10. package/components/form/combobox/Combobox.vue +21 -34
  11. package/components/form/number-field/NumberField.vue +0 -1
  12. package/components/form/record-form/RecordFields.vue +128 -0
  13. package/components/form/record-form/RecordForm.vue +116 -0
  14. package/components/form/record-form/fields.ts +20 -0
  15. package/components/form/record-form/record-form.css +15 -0
  16. package/components/form/record-form/types.ts +28 -0
  17. package/components/form/text-input/text-input.css +2 -0
  18. package/components/layout/index/Index.vue +353 -0
  19. package/components/layout/index/index.css +114 -0
  20. package/components/layout/index/useIndex.ts +390 -0
  21. package/components/layout/table/table.css +2 -1
  22. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  23. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  24. package/components/navigation/sidebar/Sidebar.vue +11 -8
  25. package/components/navigation/tabs/Tabs.vue +6 -0
  26. package/composables/useMenu.ts +20 -27
  27. package/package.json +12 -2
  28. package/styles/0-settings/colors.css +10 -0
  29. package/utils/a11y/focus.ts +9 -3
  30. package/utils/cms/index.ts +283 -0
  31. package/utils/cms/schema.json +126 -0
@@ -146,7 +146,6 @@ function handleStep(direction: 'decrement' | 'increment'): void {
146
146
  // listeners observe stepper clicks the same as typed edits.
147
147
  input.dispatchEvent(new Event('input', { bubbles: true }));
148
148
  input.dispatchEvent(new Event('change', { bubbles: true }));
149
- emit('update:modelValue', input.valueAsNumber);
150
149
  }
151
150
  </script>
152
151
 
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ export type { RecordFormField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { computed } from 'vue';
7
+ import TextInput from '../text-input/TextInput.vue';
8
+ import PuiTextarea from '../textarea/Textarea.vue';
9
+ import PuiSelect from '../select/Select.vue';
10
+ import Checkbox from '../checkbox/Checkbox.vue';
11
+ import {
12
+ readPath,
13
+ updatePath,
14
+ type ValidationIssue,
15
+ type ValuePath,
16
+ } from '../../../utils/cms/index.js';
17
+ import type { RecordFormField } from './types.js';
18
+ import { pathFor, messagesFor } from './fields.js';
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ modelValue: T;
23
+ fields: readonly RecordFormField[];
24
+ issues?: readonly ValidationIssue[];
25
+ busy?: boolean;
26
+ }>(),
27
+ { issues: () => [], busy: false },
28
+ );
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: T];
31
+ 'field-change': [path: ValuePath];
32
+ }>();
33
+ const fieldsWithErrors = computed(() =>
34
+ props.fields.map((field) => ({
35
+ field,
36
+ errors: messagesFor(field, props.issues),
37
+ })),
38
+ );
39
+
40
+ function valueFor(field: RecordFormField): unknown {
41
+ return readPath(props.modelValue, pathFor(field));
42
+ }
43
+ function textFor(field: RecordFormField): string {
44
+ const value = valueFor(field);
45
+ return typeof value === 'string' ? value : '';
46
+ }
47
+ function update(field: RecordFormField, value: unknown): void {
48
+ if (props.busy || field.disabled) return;
49
+ const path = pathFor(field);
50
+ emit('update:modelValue', updatePath(props.modelValue, path, value));
51
+ emit('field-change', path);
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="pui-record-fields">
57
+ <template
58
+ v-for="{ field, errors } in fieldsWithErrors"
59
+ :key="field.name"
60
+ >
61
+ <slot name="before-field" :field="field" />
62
+ <slot
63
+ :name="`field:${field.name}`"
64
+ :field="field"
65
+ :value="valueFor(field)"
66
+ :update="(value: unknown) => update(field, value)"
67
+ :errors="errors"
68
+ :disabled="busy || !!field.disabled"
69
+ >
70
+ <PuiTextarea
71
+ v-if="field.type === 'textarea'"
72
+ :label="field.label"
73
+ :name="JSON.stringify(pathFor(field))"
74
+ :model-value="textFor(field)"
75
+ :hint="field.hint"
76
+ :required="field.required"
77
+ :disabled="busy || field.disabled"
78
+ :placeholder="field.placeholder"
79
+ :rows="field.rows"
80
+ :errors="errors"
81
+ @update:model-value="update(field, $event)"
82
+ />
83
+ <PuiSelect
84
+ v-else-if="field.type === 'select'"
85
+ :label="field.label"
86
+ :name="JSON.stringify(pathFor(field))"
87
+ :model-value="textFor(field)"
88
+ :hint="field.hint"
89
+ :required="field.required"
90
+ :disabled="busy || field.disabled"
91
+ :placeholder="field.placeholder"
92
+ :options="field.options"
93
+ :errors="errors"
94
+ @update:model-value="update(field, $event)"
95
+ />
96
+ <Checkbox
97
+ v-else-if="field.type === 'checkbox'"
98
+ :label="field.label"
99
+ :name="JSON.stringify(pathFor(field))"
100
+ :model-value="valueFor(field) === true"
101
+ :hint="field.hint"
102
+ :required="field.required"
103
+ :disabled="busy || field.disabled"
104
+ :errors="errors"
105
+ @update:model-value="update(field, $event)"
106
+ />
107
+ <TextInput
108
+ v-else-if="field.type !== 'custom'"
109
+ :label="field.label"
110
+ :name="JSON.stringify(pathFor(field))"
111
+ :model-value="textFor(field)"
112
+ :type="field.type ?? 'text'"
113
+ :hint="field.hint"
114
+ :required="field.required"
115
+ :disabled="busy || field.disabled"
116
+ :placeholder="field.placeholder"
117
+ :autocomplete="field.autocomplete"
118
+ :errors="errors"
119
+ @update:model-value="update(field, $event)"
120
+ />
121
+ </slot>
122
+ </template>
123
+ </div>
124
+ </template>
125
+
126
+ <style>
127
+ @import './record-form.css';
128
+ </style>
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ export type { RecordFormField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { computed } from 'vue';
7
+ import Button from '../../action/button/Button.vue';
8
+ import Form from '../form/Form.vue';
9
+ import RecordFields from './RecordFields.vue';
10
+ import {
11
+ cloneRecord,
12
+ type ValidationIssue,
13
+ type ValuePath,
14
+ } from '../../../utils/cms/index.js';
15
+ import { pathFor, containsPath, messagesFor } from './fields.js';
16
+ import type { RecordFormField } from './types.js';
17
+
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ /** Caller-owned write payload; every supplied property is retained. */
21
+ modelValue: T;
22
+ fields: readonly RecordFormField[];
23
+ issues?: readonly ValidationIssue[];
24
+ errors?: string[];
25
+ busy?: boolean;
26
+ submitLabel?: string;
27
+ statusMessage?: string;
28
+ }>(),
29
+ {
30
+ issues: () => [],
31
+ errors: () => [],
32
+ busy: false,
33
+ submitLabel: 'Save changes',
34
+ statusMessage: undefined,
35
+ },
36
+ );
37
+
38
+ const emit = defineEmits<{
39
+ 'update:modelValue': [value: T];
40
+ 'field-change': [path: ValuePath];
41
+ submit: [value: T];
42
+ }>();
43
+
44
+ const fieldErrors = computed(() =>
45
+ Object.fromEntries(
46
+ props.fields.map((field) => [
47
+ JSON.stringify(pathFor(field)),
48
+ messagesFor(field, props.issues),
49
+ ]),
50
+ ),
51
+ );
52
+
53
+ const summary = computed(() => [
54
+ ...props.errors,
55
+ ...props.issues.flatMap((issue) => {
56
+ const field = props.fields.find((field) =>
57
+ containsPath(pathFor(field), issue.path),
58
+ );
59
+ const label = field?.label ?? issue.path.join(' / ');
60
+ return issue.messages.map((message) =>
61
+ label ? `${label}: ${message}` : message,
62
+ );
63
+ }),
64
+ ]);
65
+
66
+ function submit(): void {
67
+ if (!props.busy) emit('submit', cloneRecord(props.modelValue));
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <Form
73
+ class="pui-record-form"
74
+ :busy="busy"
75
+ :errors="summary"
76
+ :field-errors="fieldErrors"
77
+ :status-message="statusMessage"
78
+ @submit="submit"
79
+ >
80
+ <slot name="fields">
81
+ <RecordFields
82
+ class="pui-record-form__fields"
83
+ :model-value="modelValue"
84
+ :fields="fields"
85
+ :issues="issues"
86
+ :busy="busy"
87
+ @update:model-value="emit('update:modelValue', $event)"
88
+ @field-change="emit('field-change', $event)"
89
+ >
90
+ <template
91
+ v-for="name in Object.keys($slots).filter(
92
+ (name) =>
93
+ name === 'before-field' ||
94
+ name.startsWith('field:'),
95
+ )"
96
+ :key="name"
97
+ #[name]="scope"
98
+ >
99
+ <slot :name="name" v-bind="scope" />
100
+ </template>
101
+ </RecordFields>
102
+ </slot>
103
+ <slot />
104
+ <template #actions>
105
+ <slot name="actions" :busy="busy">
106
+ <Button type="submit" variant="primary" :loading="busy">
107
+ {{ submitLabel }}
108
+ </Button>
109
+ </slot>
110
+ </template>
111
+ </Form>
112
+ </template>
113
+
114
+ <style>
115
+ @import './record-form.css';
116
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { ValidationIssue, ValuePath } from '../../../utils/cms/index.js';
2
+ import type { RecordFormField } from './types.js';
3
+
4
+ export function pathFor(field: RecordFormField): ValuePath {
5
+ return field.path ?? [field.name];
6
+ }
7
+ export function containsPath(parent: ValuePath, child: ValuePath): boolean {
8
+ return (
9
+ parent.length <= child.length &&
10
+ parent.every((part, index) => part === child[index])
11
+ );
12
+ }
13
+ export function messagesFor(
14
+ field: RecordFormField,
15
+ issues: readonly ValidationIssue[],
16
+ ): string[] {
17
+ return issues
18
+ .filter((issue) => containsPath(pathFor(field), issue.path))
19
+ .flatMap((issue) => issue.messages);
20
+ }
@@ -0,0 +1,15 @@
1
+ @layer components {
2
+ .pui-record-form {
3
+ gap: var(--space-s);
4
+ }
5
+
6
+ .pui-record-fields {
7
+ display: grid;
8
+ grid-template-columns: var(
9
+ --pui-record-fields-columns,
10
+ var(--pui-record-form-columns, minmax(0, 1fr))
11
+ );
12
+ gap: var(--space-s);
13
+ min-inline-size: 0;
14
+ }
15
+ }
@@ -0,0 +1,28 @@
1
+ import type { ValuePath } from '../../../utils/cms/index.js';
2
+
3
+ interface RecordFieldBase {
4
+ name: string;
5
+ label: string;
6
+ /** Defaults to the literal property name, without splitting dots. */
7
+ path?: ValuePath;
8
+ hint?: string;
9
+ required?: boolean;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export type RecordFormField = RecordFieldBase &
14
+ (
15
+ | {
16
+ type?: 'text' | 'email';
17
+ placeholder?: string;
18
+ autocomplete?: string;
19
+ }
20
+ | { type: 'textarea'; placeholder?: string; rows?: number }
21
+ | {
22
+ type: 'select';
23
+ options: { value: string; label: string }[];
24
+ placeholder?: string;
25
+ }
26
+ | { type: 'checkbox' }
27
+ | { type: 'custom' }
28
+ );
@@ -16,6 +16,7 @@
16
16
 
17
17
  .pui-field {
18
18
  display: grid;
19
+ align-content: start;
19
20
  gap: var(--space-2xs);
20
21
  }
21
22
 
@@ -59,6 +60,7 @@
59
60
  }
60
61
 
61
62
  .pui-field__label {
63
+ margin-block: 0;
62
64
  font-size: var(--step--1);
63
65
  font-weight: var(--fw-bold);
64
66
  }
@@ -0,0 +1,353 @@
1
+ <template>
2
+ <div class="pui-index" :data-state="loading ? 'loading' : undefined">
3
+ <header
4
+ v-if="title || description || $slots.actions"
5
+ class="pui-index__header"
6
+ >
7
+ <div v-if="title || description" class="pui-index__heading">
8
+ <h1 v-if="title" class="pui-index__title">{{ title }}</h1>
9
+ <p v-if="description" class="pui-index__description">
10
+ {{ description }}
11
+ </p>
12
+ </div>
13
+ <div v-if="$slots.actions" class="pui-index__actions">
14
+ <slot name="actions" :reload="reload" :loading="loading" />
15
+ </div>
16
+ </header>
17
+
18
+ <div class="pui-index__body">
19
+ <div
20
+ v-if="
21
+ !invalidQuery &&
22
+ (searchable || $slots.filters || queryOptions.sorts.length)
23
+ "
24
+ class="pui-index__toolbar"
25
+ >
26
+ <div v-if="searchable" class="pui-index__search">
27
+ <TextInput
28
+ type="search"
29
+ :label="searchLabel"
30
+ :placeholder="searchPlaceholder"
31
+ :model-value="search"
32
+ @update:model-value="setSearch"
33
+ />
34
+ </div>
35
+ <slot
36
+ name="filters"
37
+ :filters="query.filters"
38
+ :set-filters="setFilters"
39
+ :set-filter="setFilter"
40
+ />
41
+ <div v-if="queryOptions.sorts.length" class="pui-index__sort">
42
+ <Select
43
+ :label="sortLabel"
44
+ :aria-label="sortLabel"
45
+ :model-value="formatSort(query.sort) ?? ''"
46
+ :options="sortOptions"
47
+ @update:model-value="
48
+ updateCriteria({ sort: parseSort($event) })
49
+ "
50
+ />
51
+ </div>
52
+ </div>
53
+
54
+ <div
55
+ v-if="selectable && selected.length && $slots.selection"
56
+ class="pui-index__selection"
57
+ >
58
+ <slot
59
+ name="selection"
60
+ :selected="selected"
61
+ :reload="reload"
62
+ :loading="loading"
63
+ />
64
+ </div>
65
+
66
+ <div v-if="errors.length" class="pui-index__error">
67
+ <slot
68
+ name="error"
69
+ :error="error"
70
+ :errors="errors"
71
+ :retry="reload"
72
+ :reset="reset"
73
+ :invalid-query="invalidQuery"
74
+ >
75
+ <Alert
76
+ tone="danger"
77
+ :title="
78
+ invalidQuery
79
+ ? 'This list query is invalid'
80
+ : 'Could not load results'
81
+ "
82
+ >
83
+ <ul>
84
+ <li v-for="message in errors" :key="message">
85
+ {{ message }}
86
+ </li>
87
+ </ul>
88
+ <Button
89
+ size="sm"
90
+ @click="invalidQuery ? reset() : reload()"
91
+ >{{
92
+ invalidQuery ? 'Reset filters' : 'Try again'
93
+ }}</Button
94
+ >
95
+ </Alert>
96
+ </slot>
97
+ </div>
98
+
99
+ <div v-if="showResults" class="pui-index__results">
100
+ <DataTable
101
+ class="pui-index__table"
102
+ :rows="rows"
103
+ :columns="columns"
104
+ :row-key="rowKey"
105
+ :sort="query.sort"
106
+ :loading="loading"
107
+ :selectable="selectable"
108
+ :selected="selected"
109
+ :clickable="clickable"
110
+ @sort="
111
+ updateCriteria({ sort: toggleSort(query.sort, $event) })
112
+ "
113
+ @update:selected="emit('update:selected', $event)"
114
+ @row-click="emit('row-click', $event)"
115
+ >
116
+ <template
117
+ v-for="name in cellSlots"
118
+ :key="name"
119
+ #[name]="scope"
120
+ >
121
+ <slot :name="name" v-bind="scope" />
122
+ </template>
123
+ <template #empty
124
+ ><slot name="empty">No results.</slot></template
125
+ >
126
+ </DataTable>
127
+ </div>
128
+ </div>
129
+
130
+ <PaginationFooter v-if="showResults" class="pui-index__footer">
131
+ <template #summary>
132
+ <slot name="summary" :total="total" :from="from" :to="to">
133
+ {{ from }}–{{ to }} of {{ total }}
134
+ </slot>
135
+ </template>
136
+ <div class="pui-index__pagination">
137
+ <div v-if="pageSizes.length" class="pui-index__page-size">
138
+ <Select
139
+ :label="pageSizeLabel"
140
+ :aria-label="pageSizeLabel"
141
+ :model-value="String(query.page_size)"
142
+ :options="pageSizeOptions"
143
+ @update:model-value="
144
+ updateCriteria({
145
+ page_size: Number($event),
146
+ })
147
+ "
148
+ />
149
+ </div>
150
+ <Pagination
151
+ :current-page="query.page"
152
+ :total-pages="totalPages"
153
+ @update:current-page="setPage($event)"
154
+ />
155
+ </div>
156
+ </PaginationFooter>
157
+ </div>
158
+ </template>
159
+
160
+ <script setup lang="ts" generic="T extends object">
161
+ import { computed, watch } from 'vue';
162
+ import DataTable from '../table/DataTable.vue';
163
+ import type { Column } from '../table/types.js';
164
+ import TextInput from '../../form/text-input/TextInput.vue';
165
+ import Select from '../../form/select/Select.vue';
166
+ import Alert from '../../feedback/alert/Alert.vue';
167
+ import Pagination from '../../navigation/pagination/Pagination.vue';
168
+ import PaginationFooter from '../../navigation/pagination/PaginationFooter.vue';
169
+ import type {
170
+ ApiError,
171
+ ListQuery,
172
+ ListLoader,
173
+ QueryOptions,
174
+ } from '../../../utils/cms/index.js';
175
+ import Button from '../../action/button/Button.vue';
176
+ import { useIndex } from './useIndex.js';
177
+ import {
178
+ formatSort,
179
+ parseSort,
180
+ toggleSort,
181
+ } from '../../../utils/sort/index.js';
182
+
183
+ const props = withDefaults(
184
+ defineProps<{
185
+ /** Returns the matching page using the shared backend response contract. */
186
+ load: ListLoader<T>;
187
+ columns: Column[];
188
+ queryOptions: QueryOptions;
189
+ /** Synchronize list parameters with the current Vue Router route. */
190
+ syncQuery?: boolean;
191
+ /** Delay in milliseconds before committing typed search text. */
192
+ searchDebounce?: number;
193
+ title?: string;
194
+ description?: string;
195
+ searchable?: boolean;
196
+ searchLabel?: string;
197
+ searchPlaceholder?: string;
198
+ sortLabel?: string;
199
+ /** Labels for sort keys, including fields without a visible column. */
200
+ sortLabels?: Record<string, string>;
201
+ /** An empty array hides the page-size control. */
202
+ pageSizes?: number[];
203
+ pageSizeLabel?: string;
204
+ rowKey?: string;
205
+ selectable?: boolean;
206
+ selected?: (string | number)[];
207
+ clickable?: boolean;
208
+ }>(),
209
+ {
210
+ syncQuery: false,
211
+ searchDebounce: 250,
212
+ title: undefined,
213
+ description: undefined,
214
+ searchable: true,
215
+ searchLabel: 'Search',
216
+ searchPlaceholder: undefined,
217
+ sortLabel: 'Sort by',
218
+ sortLabels: () => ({}),
219
+ pageSizes: () => [10, 20, 50],
220
+ pageSizeLabel: 'Rows per page',
221
+ rowKey: 'id',
222
+ selectable: false,
223
+ selected: () => [],
224
+ clickable: false,
225
+ },
226
+ );
227
+
228
+ const emit = defineEmits<{
229
+ 'update:selected': [keys: (string | number)[]];
230
+ 'row-click': [row: T];
231
+ }>();
232
+
233
+ const slots = defineSlots<{
234
+ actions?: (scope: {
235
+ reload: () => Promise<void>;
236
+ loading: boolean;
237
+ }) => unknown;
238
+ filters?: (scope: {
239
+ filters: ListQuery['filters'];
240
+ setFilters: (filters: ListQuery['filters']) => void;
241
+ setFilter: (name: string, value: string | undefined) => void;
242
+ }) => unknown;
243
+ selection?: (scope: {
244
+ selected: (string | number)[];
245
+ reload: () => Promise<void>;
246
+ loading: boolean;
247
+ }) => unknown;
248
+ error?: (scope: {
249
+ error: ApiError | null;
250
+ errors: string[];
251
+ retry: () => Promise<void>;
252
+ reset: () => Promise<void>;
253
+ invalidQuery: boolean;
254
+ }) => unknown;
255
+ empty?: () => unknown;
256
+ summary?: (scope: { total: number; from: number; to: number }) => unknown;
257
+ [name: `cell:${string}`]: (scope: { row: T; value: unknown }) => unknown;
258
+ }>();
259
+
260
+ const unsupportedSortColumns = computed(() =>
261
+ props.columns.filter(
262
+ (column) =>
263
+ column.sortable && !props.queryOptions.sorts.includes(column.key),
264
+ ),
265
+ );
266
+ function validateSortColumns(unsupported: Column[]) {
267
+ if (unsupported.length) {
268
+ throw new Error(
269
+ `[pui] Index sortable columns must be allowed by queryOptions.sorts: ${unsupported.map((column) => column.key).join(', ')}.`,
270
+ );
271
+ }
272
+ }
273
+ validateSortColumns(unsupportedSortColumns.value);
274
+ watch(unsupportedSortColumns, validateSortColumns);
275
+
276
+ const {
277
+ query,
278
+ search,
279
+ rows,
280
+ meta,
281
+ loading,
282
+ error,
283
+ errors,
284
+ invalidQuery,
285
+ updateCriteria,
286
+ setSearch,
287
+ setPage,
288
+ setFilters,
289
+ setFilter,
290
+ reload,
291
+ reset,
292
+ } = useIndex<T>(props);
293
+ defineExpose({ reload });
294
+ const total = computed(() => meta.value.total);
295
+ const showResults = computed(
296
+ () =>
297
+ !invalidQuery.value &&
298
+ (!errors.value.length || rows.value.length > 0 || loading.value),
299
+ );
300
+ const sortOptions = computed(() => [
301
+ { value: '', label: 'Default order' },
302
+ ...props.queryOptions.sorts.flatMap((key) => {
303
+ const label =
304
+ props.sortLabels[key] ??
305
+ props.columns.find((column) => column.key === key)?.label ??
306
+ key
307
+ .replaceAll('_', ' ')
308
+ .replace(/^./, (letter) => letter.toUpperCase());
309
+ return [
310
+ {
311
+ value: formatSort({ key, direction: 'asc' })!,
312
+ label: `${label} (ascending)`,
313
+ },
314
+ {
315
+ value: formatSort({ key, direction: 'desc' })!,
316
+ label: `${label} (descending)`,
317
+ },
318
+ ];
319
+ }),
320
+ ]);
321
+
322
+ const cellSlots = computed(() =>
323
+ props.columns
324
+ .map((column) => `cell:${column.key}` as const)
325
+ .filter((name) => slots[name]),
326
+ );
327
+ const totalPages = computed(() =>
328
+ Math.max(1, Math.ceil(total.value / query.value.page_size)),
329
+ );
330
+ const from = computed(() =>
331
+ rows.value.length ? (meta.value.page - 1) * meta.value.page_size + 1 : 0,
332
+ );
333
+ const to = computed(() =>
334
+ rows.value.length
335
+ ? Math.min(total.value, from.value + rows.value.length - 1)
336
+ : 0,
337
+ );
338
+ const pageSizeOptions = computed(() =>
339
+ [...new Set([...props.pageSizes, query.value.page_size])]
340
+ .filter(
341
+ (size) =>
342
+ Number.isInteger(size) &&
343
+ size > 0 &&
344
+ size <= (props.queryOptions.maxPageSize ?? 100),
345
+ )
346
+ .sort((a, b) => a - b)
347
+ .map((size) => ({ value: String(size), label: String(size) })),
348
+ );
349
+ </script>
350
+
351
+ <style>
352
+ @import './index.css';
353
+ </style>