@pienter/ui 0.3.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 (109) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/CONVENTIONS.md +342 -385
  3. package/README.md +33 -18
  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/feedback/toast/toast.css +1 -1
  8. package/components/form/block-editor/BlockEditor.vue +454 -0
  9. package/components/form/block-editor/block-editor.css +149 -0
  10. package/components/form/block-editor/types.ts +15 -0
  11. package/components/form/combobox/Combobox.vue +42 -72
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/form/Form.vue +1 -2
  14. package/components/form/label/label.css +1 -1
  15. package/components/form/number-field/NumberField.vue +1 -2
  16. package/components/form/number-field/number-field.css +1 -1
  17. package/components/form/radio-group/RadioGroup.vue +2 -5
  18. package/components/form/record-form/RecordFields.vue +128 -0
  19. package/components/form/record-form/RecordForm.vue +116 -0
  20. package/components/form/record-form/fields.ts +20 -0
  21. package/components/form/record-form/record-form.css +15 -0
  22. package/components/form/record-form/types.ts +28 -0
  23. package/components/form/slider/slider.css +2 -3
  24. package/components/form/tags-input/tags-input.css +1 -2
  25. package/components/form/text-input/text-input.css +2 -0
  26. package/components/form/textarea/textarea.css +1 -1
  27. package/components/layout/accordion/Accordion.vue +6 -27
  28. package/components/layout/collapsible/Collapsible.vue +9 -19
  29. package/components/layout/index/Index.vue +353 -0
  30. package/components/layout/index/index.css +114 -0
  31. package/components/layout/index/useIndex.ts +390 -0
  32. package/components/layout/table/table.css +5 -4
  33. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  34. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  35. package/components/navigation/sidebar/Sidebar.vue +329 -84
  36. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  37. package/components/navigation/sidebar/context.ts +21 -0
  38. package/components/navigation/sidebar/sidebar.css +346 -78
  39. package/components/navigation/sidebar/types.ts +13 -1
  40. package/components/navigation/tabs/Tabs.vue +6 -0
  41. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  42. package/components/overlay/command/Command.vue +10 -38
  43. package/components/overlay/command/command.css +1 -1
  44. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  45. package/components/overlay/modal/Modal.vue +7 -28
  46. package/components/overlay/popover/Popover.vue +9 -31
  47. package/components/overlay/sheet/Sheet.vue +7 -28
  48. package/components/overlay/tooltip/Tooltip.vue +14 -19
  49. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  50. package/composables/useDisclosure.ts +113 -0
  51. package/composables/useEventListener.ts +16 -0
  52. package/composables/useMenu.ts +212 -0
  53. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  54. package/package.json +16 -40
  55. package/styles/0-settings/colors.css +10 -0
  56. package/styles/0-settings/layout.css +18 -0
  57. package/styles/0-settings/motion.css +2 -2
  58. package/styles/0-settings/spacing.css +3 -1
  59. package/utils/a11y/focus.ts +9 -3
  60. package/utils/cms/index.ts +283 -0
  61. package/utils/cms/schema.json +126 -0
  62. package/utils/navigation/sidebar.ts +97 -0
  63. package/utils/validation/form.ts +6 -9
  64. package/components/action/button/Button.astro +0 -95
  65. package/components/action/button/IconButton.astro +0 -86
  66. package/components/display/avatar/Avatar.astro +0 -17
  67. package/components/display/avatar/AvatarStack.astro +0 -9
  68. package/components/display/badge/Badge.astro +0 -15
  69. package/components/display/empty/Empty.astro +0 -9
  70. package/components/display/icon/Icon.astro +0 -52
  71. package/components/feedback/alert/Alert.astro +0 -52
  72. package/components/feedback/progress/Progress.astro +0 -68
  73. package/components/feedback/skeleton/Skeleton.astro +0 -32
  74. package/components/feedback/spinner/Spinner.astro +0 -25
  75. package/components/feedback/toast/Toast.astro +0 -50
  76. package/components/form/checkbox/Checkbox.astro +0 -79
  77. package/components/form/date-input/DateInput.astro +0 -105
  78. package/components/form/form/Form.astro +0 -106
  79. package/components/form/input-otp/InputOTP.astro +0 -147
  80. package/components/form/label/Label.astro +0 -13
  81. package/components/form/number-field/NumberField.astro +0 -142
  82. package/components/form/radio-group/RadioGroup.astro +0 -105
  83. package/components/form/select/Select.astro +0 -105
  84. package/components/form/slider/Slider.astro +0 -205
  85. package/components/form/switch/Switch.astro +0 -75
  86. package/components/form/tags-input/TagsInput.astro +0 -153
  87. package/components/form/text-input/TextInput.astro +0 -84
  88. package/components/form/textarea/Textarea.astro +0 -86
  89. package/components/layout/card/Card.astro +0 -13
  90. package/components/layout/separator/Separator.astro +0 -31
  91. package/components/layout/table/Table.astro +0 -116
  92. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  93. package/components/navigation/navbar/Navbar.astro +0 -62
  94. package/components/navigation/pagination/PaginationFooter.astro +0 -24
  95. package/components/navigation/sidebar/Sidebar.astro +0 -132
  96. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  97. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  98. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  99. package/components/overlay/modal/Modal.astro +0 -66
  100. package/utils/controllers/disclosure.ts +0 -117
  101. package/utils/controllers/form.ts +0 -524
  102. package/utils/controllers/index.ts +0 -39
  103. package/utils/controllers/menu.ts +0 -255
  104. package/utils/controllers/number-field.ts +0 -103
  105. package/utils/controllers/otp.ts +0 -252
  106. package/utils/controllers/sidebar.ts +0 -610
  107. package/utils/controllers/slider.ts +0 -336
  108. package/utils/controllers/tags-input.ts +0 -255
  109. package/utils/controllers/toast.ts +0 -426
@@ -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
+ );
@@ -5,9 +5,8 @@
5
5
  * horizontal track that hosts a faint background rail, a coloured
6
6
  * fill segment representing the current value (or range), and one
7
7
  * or two `<button role="slider">` thumbs. Each thumb is absolutely
8
- * positioned via `inset-inline-start: <pct>%`; the controller
9
- * (Astro) and Vue's reactive computed both write the same
10
- * percentage so the visual is identical across both tiers.
8
+ * positioned via `inset-inline-start: <pct>%`, written by Vue's
9
+ * reactive computed.
11
10
  *
12
11
  * Layout rationale: rejected the native `<input type="range">`
13
12
  * baseline for two reasons — (a) cross-browser thumb / track
@@ -6,8 +6,7 @@
6
6
  * row that hosts existing tag pills followed by a text input where
7
7
  * the user types the next tag. The whole row reads as one
8
8
  * integrated control: clicking anywhere lands focus on the entry
9
- * input (handled by Vue / via the click handler in
10
- * `mountTagsInput`).
9
+ * input (a click handler in `TagsInput.vue`).
11
10
  *
12
11
  * Layout rationale: rejected the "fixed-height pills + horizontally-
13
12
  * scrolling row" alternative because vertical wrapping reads better
@@ -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
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  /* Auto-resize variant — opt-in via the `autoResize` prop, which sets
15
15
  * `style="field-sizing: content"` inline on the <textarea>. The
16
- * inline style is the only documented Vue/Astro path, so the
16
+ * inline style is the only documented path, so the
17
17
  * selector matches it directly. Browsers without `field-sizing`
18
18
  * support fall back gracefully to the default fixed-rows/min-height
19
19
  * box. */
@@ -5,8 +5,8 @@
5
5
  </template>
6
6
 
7
7
  <script setup lang="ts">
8
- import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
9
- import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
8
+ import { ref } from 'vue';
9
+ import { useDisclosure } from '../../../composables/useDisclosure.js';
10
10
 
11
11
  const props = withDefaults(
12
12
  defineProps<{
@@ -25,32 +25,11 @@ const props = withDefaults(
25
25
  );
26
26
 
27
27
  const rootRef = ref<HTMLDivElement | null>(null);
28
- let teardown: (() => void) | null = null;
29
28
 
30
- function mount(): void {
31
- if (!rootRef.value) return;
32
- teardown = mountDisclosure(rootRef.value, {
33
- singleOpen: props.singleOpen,
34
- });
35
- }
36
-
37
- onMounted(mount);
38
-
39
- // `singleOpen` is read once at mount time. If a consumer toggles it
40
- // at runtime we re-mount so the new policy takes effect immediately.
41
- // This is rare (the prop is typically static per surface) but cheap.
42
- watch(
43
- () => props.singleOpen,
44
- () => {
45
- teardown?.();
46
- teardown = null;
47
- mount();
48
- },
49
- );
50
-
51
- onBeforeUnmount(() => {
52
- teardown?.();
53
- teardown = null;
29
+ // `singleOpen` is read per toggle, so a runtime change takes effect on
30
+ // the next interaction without re-wiring.
31
+ useDisclosure(rootRef, {
32
+ singleOpen: () => props.singleOpen,
54
33
  });
55
34
  </script>
56
35
 
@@ -17,8 +17,8 @@
17
17
  </template>
18
18
 
19
19
  <script setup lang="ts">
20
- import { onBeforeUnmount, onMounted, ref } from 'vue';
21
- import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
20
+ import { ref } from 'vue';
21
+ import { useDisclosure } from '../../../composables/useDisclosure.js';
22
22
  import Icon from '../../display/icon/Icon.vue';
23
23
 
24
24
  withDefaults(
@@ -51,24 +51,14 @@ const emit = defineEmits<{
51
51
  }>();
52
52
 
53
53
  const rootRef = ref<HTMLDetailsElement | null>(null);
54
- let teardown: (() => void) | null = null;
55
54
 
56
- onMounted(() => {
57
- if (!rootRef.value) return;
58
- // Collapsible is single-disclosure: no `singleOpen` coordination
59
- // needed. The controller's only role here is to provide the
60
- // standard mount/teardown lifecycle and a normalized callback —
61
- // the native `<details>` element does the open/close work itself.
62
- teardown = mountDisclosure(rootRef.value, {
63
- onToggle: (_target, open) => {
64
- emit('update:open', open);
65
- },
66
- });
67
- });
68
-
69
- onBeforeUnmount(() => {
70
- teardown?.();
71
- teardown = null;
55
+ // Collapsible is single-disclosure: no `singleOpen` coordination
56
+ // needed. The composable's only role here is a normalized callback —
57
+ // the native `<details>` element does the open/close work itself.
58
+ useDisclosure(rootRef, {
59
+ onToggle: (_target, open) => {
60
+ emit('update:open', open);
61
+ },
72
62
  });
73
63
  </script>
74
64
 
@@ -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>
@@ -0,0 +1,114 @@
1
+ @layer components {
2
+ .pui-index {
3
+ display: grid;
4
+ min-inline-size: 0;
5
+ gap: var(--space-s);
6
+ color: var(--text-clr-base);
7
+ }
8
+
9
+ .pui-index__header {
10
+ display: grid;
11
+ grid-template-columns: minmax(0, 1fr) auto;
12
+ align-items: center;
13
+ justify-content: space-between;
14
+ gap: var(--space-s);
15
+ padding-block-end: var(--space-s);
16
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
17
+ }
18
+
19
+ .pui-index__heading {
20
+ display: grid;
21
+ gap: var(--space-3xs);
22
+ min-inline-size: 0;
23
+ }
24
+
25
+ .pui-index__title {
26
+ margin: 0;
27
+ font-family: var(--ff-display);
28
+ font-size: var(--step-2);
29
+ font-weight: var(--fw-display);
30
+ letter-spacing: var(--ls-display);
31
+ }
32
+
33
+ .pui-index__description {
34
+ margin: 0;
35
+ font-size: var(--step--1);
36
+ color: var(--text-clr-muted);
37
+ }
38
+
39
+ .pui-index__actions,
40
+ .pui-index__selection {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ align-items: center;
44
+ gap: var(--space-xs);
45
+ }
46
+
47
+ .pui-index__body {
48
+ min-inline-size: 0;
49
+ background: var(--bg-clr-surface);
50
+ border: var(--stroke-sm) solid var(--border-clr-subtle);
51
+ border-radius: var(--radius-md);
52
+ }
53
+
54
+ .pui-index__selection,
55
+ .pui-index__error {
56
+ padding: var(--space-s);
57
+ }
58
+
59
+ .pui-index__toolbar {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ align-items: end;
63
+ gap: var(--space-s);
64
+ padding: var(--space-s);
65
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
66
+ }
67
+
68
+ .pui-index__search {
69
+ flex: 0 1 calc(var(--space-sidebar-width) * 1.5);
70
+ min-inline-size: min(100%, var(--space-sidebar-width));
71
+
72
+ @media (max-width: 48em) {
73
+ flex-grow: 1;
74
+ }
75
+ }
76
+
77
+ .pui-index__sort {
78
+ flex: 0 1 auto;
79
+ min-inline-size: 0;
80
+ max-inline-size: 100%;
81
+ }
82
+
83
+ .pui-index__results {
84
+ min-inline-size: 0;
85
+ }
86
+
87
+ .pui-index__table {
88
+ --pui-table-cell-padding-block: var(--space-2xs);
89
+ border: 0;
90
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
91
+ }
92
+
93
+ .pui-index__page-size > .pui-field {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: var(--space-xs);
97
+
98
+ & > .pui-field__label {
99
+ white-space: nowrap;
100
+ }
101
+ }
102
+
103
+ .pui-index__pagination {
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ align-items: center;
107
+ justify-content: end;
108
+ gap: var(--space-s);
109
+
110
+ & > .pui-pagination-nav {
111
+ flex: 1;
112
+ }
113
+ }
114
+ }