@pienter/ui 0.5.0 → 0.8.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 (66) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/CONVENTIONS.md +91 -28
  3. package/README.md +66 -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/feedback/toast/Toast.vue +3 -4
  8. package/components/feedback/toast/ToastHost.vue +165 -0
  9. package/components/feedback/toast/toast.ts +116 -0
  10. package/components/feedback/toast/types.ts +48 -0
  11. package/components/form/block-editor/BlockEditor.vue +455 -0
  12. package/components/form/block-editor/block-editor.css +149 -0
  13. package/components/form/block-editor/types.ts +15 -0
  14. package/components/form/checkbox/Checkbox.vue +10 -2
  15. package/components/form/combobox/Combobox.vue +32 -39
  16. package/components/form/combobox/combobox.css +1 -1
  17. package/components/form/date-input/DateInput.vue +10 -2
  18. package/components/form/date-input/date-input.css +2 -17
  19. package/components/form/form/Form.vue +10 -9
  20. package/components/form/form/form.css +2 -22
  21. package/components/form/input-otp/InputOTP.vue +1 -1
  22. package/components/form/input-otp/input-otp.css +1 -1
  23. package/components/form/label/label.css +2 -11
  24. package/components/form/number-field/NumberField.vue +10 -3
  25. package/components/form/number-field/number-field.css +3 -3
  26. package/components/form/radio-group/RadioGroup.vue +1 -1
  27. package/components/form/record-form/RecordFields.vue +128 -0
  28. package/components/form/record-form/RecordForm.vue +116 -0
  29. package/components/form/record-form/fields.ts +20 -0
  30. package/components/form/record-form/record-form.css +15 -0
  31. package/components/form/record-form/types.ts +28 -0
  32. package/components/form/select/Select.vue +13 -4
  33. package/components/form/select/select.css +6 -9
  34. package/components/form/slider/Slider.vue +1 -1
  35. package/components/form/switch/Switch.vue +1 -1
  36. package/components/form/tags-input/TagsInput.vue +17 -2
  37. package/components/form/tags-input/tags-input.css +16 -12
  38. package/components/form/text-input/TextInput.vue +10 -2
  39. package/components/form/text-input/text-input.css +10 -129
  40. package/components/form/textarea/Textarea.vue +10 -2
  41. package/components/form/textarea/textarea.css +3 -19
  42. package/components/layout/app-layout/AppLayout.vue +116 -0
  43. package/components/layout/app-layout/app-layout.css +115 -0
  44. package/components/layout/index/Index.vue +373 -0
  45. package/components/layout/index/index.css +157 -0
  46. package/components/layout/index/useIndex.ts +407 -0
  47. package/components/layout/table/DataTable.vue +14 -1
  48. package/components/layout/table/Table.vue +12 -0
  49. package/components/layout/table/table.css +2 -1
  50. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  51. package/components/navigation/breadcrumb/breadcrumb.css +20 -0
  52. package/components/navigation/sidebar/Sidebar.vue +11 -8
  53. package/components/navigation/sidebar/sidebar.css +23 -16
  54. package/components/navigation/tabs/Tabs.vue +6 -0
  55. package/components/navigation/tabs/tabs.css +7 -7
  56. package/composables/useMenu.ts +20 -27
  57. package/package.json +17 -2
  58. package/styles/0-settings/colors.css +10 -0
  59. package/styles/4-components/form-field.css +112 -0
  60. package/styles/4-components/index.css +1 -0
  61. package/styles/main.css +1 -0
  62. package/utils/a11y/focus.ts +9 -3
  63. package/utils/a11y/index.ts +5 -1
  64. package/utils/a11y/live-region.ts +2 -1
  65. package/utils/cms/index.ts +283 -0
  66. package/utils/cms/schema.json +126 -0
@@ -0,0 +1,115 @@
1
+ @layer components {
2
+ .pui-app-layout {
3
+ display: grid;
4
+ grid-template-rows: auto minmax(0, 1fr);
5
+ min-block-size: 100vh;
6
+
7
+ & .pui-app-layout__skip-link {
8
+ position: fixed;
9
+ inset-block-start: var(--space-2xs);
10
+ inset-inline-start: var(--space-2xs);
11
+ z-index: var(--layer-top);
12
+ inline-size: var(--stroke-sm);
13
+ block-size: var(--stroke-sm);
14
+ padding: 0;
15
+ overflow: hidden;
16
+ clip-path: inset(50%);
17
+ white-space: nowrap;
18
+
19
+ &:focus {
20
+ inline-size: auto;
21
+ block-size: auto;
22
+ padding: var(--space-2xs) var(--space-s);
23
+ overflow: visible;
24
+ clip-path: none;
25
+ border-radius: var(--radius-pill);
26
+ color: var(--text-clr-inverse);
27
+ background: var(--bg-clr-brand);
28
+ font-weight: var(--fw-semibold);
29
+ text-decoration: none;
30
+ outline: var(--outline-width) solid var(--outline-clr-base);
31
+ outline-offset: var(--outline-offset);
32
+ }
33
+ }
34
+
35
+ & .pui-app-layout__header {
36
+ position: sticky;
37
+ inset-block-start: 0;
38
+ z-index: var(--layer-4);
39
+ display: flex;
40
+ align-items: center;
41
+ gap: var(--space-s);
42
+ min-block-size: var(--space-2xl);
43
+ padding-inline: var(--space-s);
44
+ background: var(--bg-clr-surface);
45
+ border-block-end: var(--stroke-sm) solid var(--border-clr-base);
46
+ }
47
+
48
+ & .pui-app-layout__brand {
49
+ min-inline-size: 0;
50
+ }
51
+
52
+ & .pui-app-layout__tools {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: var(--space-2xs);
56
+ min-inline-size: 0;
57
+ margin-inline-start: auto;
58
+ }
59
+
60
+ & .pui-app-layout__body {
61
+ display: grid;
62
+ grid-template-columns: auto minmax(0, 1fr);
63
+ min-block-size: 0;
64
+ }
65
+
66
+ & .pui-app-layout__navigation {
67
+ min-block-size: 0;
68
+ }
69
+
70
+ & .pui-app-layout__main {
71
+ display: grid;
72
+ grid-template-rows: minmax(0, 1fr);
73
+ min-inline-size: 0;
74
+ padding: var(
75
+ --pui-app-layout-main-padding,
76
+ var(--space-m) var(--space-l) var(--space-xl)
77
+ );
78
+
79
+ &:focus:not(:focus-visible) {
80
+ outline: none;
81
+ }
82
+
83
+ &:focus-visible {
84
+ outline: var(--outline-width) solid var(--outline-clr-base);
85
+ outline-offset: calc(-1 * var(--outline-width));
86
+ }
87
+ }
88
+
89
+ & .pui-app-layout__drawer-toggle {
90
+ display: none;
91
+ flex-shrink: 0;
92
+ }
93
+
94
+ @media (min-width: 48em) {
95
+ & .pui-app-layout__navigation {
96
+ position: sticky;
97
+ inset-block-start: var(--space-2xl);
98
+ block-size: calc(100vh - var(--space-2xl));
99
+ }
100
+ }
101
+
102
+ @media (max-width: 47.999em) {
103
+ & .pui-app-layout__drawer-toggle {
104
+ display: inline-flex;
105
+ }
106
+
107
+ & .pui-app-layout__main {
108
+ padding: var(
109
+ --pui-app-layout-main-padding-mobile,
110
+ var(--space-s)
111
+ );
112
+ }
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,373 @@
1
+ <template>
2
+ <div
3
+ class="pui-index"
4
+ :data-placement="placement"
5
+ :data-state="loading ? 'loading' : undefined"
6
+ >
7
+ <header
8
+ v-if="title || description || $slots.actions"
9
+ class="pui-index__header"
10
+ >
11
+ <div v-if="title || description" class="pui-index__heading">
12
+ <component
13
+ :is="placement === 'related' ? 'h2' : 'h1'"
14
+ v-if="title"
15
+ :id="titleId"
16
+ class="pui-index__title"
17
+ >{{ title }}</component
18
+ >
19
+ <p v-if="description" class="pui-index__description">
20
+ {{ description }}
21
+ </p>
22
+ </div>
23
+ <div v-if="$slots.actions" class="pui-index__actions">
24
+ <slot name="actions" :reload="reload" :loading="loading" />
25
+ </div>
26
+ </header>
27
+
28
+ <div class="pui-index__body">
29
+ <div
30
+ v-if="
31
+ !invalidQuery &&
32
+ (searchable || $slots.filters || queryOptions.sorts.length)
33
+ "
34
+ class="pui-index__toolbar"
35
+ >
36
+ <div v-if="searchable" class="pui-index__search">
37
+ <TextInput
38
+ type="search"
39
+ :label="searchLabel"
40
+ :placeholder="searchPlaceholder"
41
+ :model-value="search"
42
+ @update:model-value="setSearch"
43
+ />
44
+ </div>
45
+ <slot
46
+ name="filters"
47
+ :filters="query.filters"
48
+ :set-filters="setFilters"
49
+ :set-filter="setFilter"
50
+ />
51
+ <div v-if="queryOptions.sorts.length" class="pui-index__sort">
52
+ <Select
53
+ :label="sortLabel"
54
+ :aria-label="sortLabel"
55
+ :model-value="formatSort(query.sort) ?? ''"
56
+ :options="sortOptions"
57
+ @update:model-value="
58
+ updateCriteria({ sort: parseSort($event) })
59
+ "
60
+ />
61
+ </div>
62
+ </div>
63
+
64
+ <div class="pui-index__selection" role="status">
65
+ <slot
66
+ v-if="selectable && selected.length && $slots.selection"
67
+ name="selection"
68
+ :selected="selected"
69
+ :reload="reload"
70
+ :loading="loading"
71
+ />
72
+ </div>
73
+
74
+ <div v-if="errors.length" class="pui-index__error">
75
+ <slot
76
+ name="error"
77
+ :error="error"
78
+ :errors="errors"
79
+ :retry="reload"
80
+ :reset="reset"
81
+ :invalid-query="invalidQuery"
82
+ >
83
+ <Alert
84
+ tone="danger"
85
+ :title="
86
+ invalidQuery
87
+ ? 'This list query is invalid'
88
+ : 'Could not load results'
89
+ "
90
+ >
91
+ <ul>
92
+ <li v-for="message in errors" :key="message">
93
+ {{ message }}
94
+ </li>
95
+ </ul>
96
+ <Button
97
+ size="sm"
98
+ @click="invalidQuery ? reset() : reload()"
99
+ >{{
100
+ invalidQuery ? 'Reset filters' : 'Try again'
101
+ }}</Button
102
+ >
103
+ </Alert>
104
+ </slot>
105
+ </div>
106
+
107
+ <div v-if="showResults" class="pui-index__results">
108
+ <DataTable
109
+ class="pui-index__table"
110
+ :rows="rows"
111
+ :columns="columns"
112
+ :row-key="rowKey"
113
+ :sort="query.sort"
114
+ :loading="loading"
115
+ :selectable="selectable"
116
+ :selected="selected"
117
+ :clickable="clickable"
118
+ :label="title ? undefined : tableLabel"
119
+ :labelledby="title ? titleId : undefined"
120
+ @sort="
121
+ updateCriteria({ sort: toggleSort(query.sort, $event) })
122
+ "
123
+ @update:selected="emit('update:selected', $event)"
124
+ @row-click="emit('row-click', $event)"
125
+ >
126
+ <template
127
+ v-for="name in cellSlots"
128
+ :key="name"
129
+ #[name]="scope"
130
+ >
131
+ <slot :name="name" v-bind="scope" />
132
+ </template>
133
+ <template #empty
134
+ ><slot name="empty">No results.</slot></template
135
+ >
136
+ </DataTable>
137
+ </div>
138
+ </div>
139
+
140
+ <PaginationFooter v-if="showResults" class="pui-index__footer">
141
+ <template #summary>
142
+ <div class="pui-index__summary" role="status">
143
+ <slot name="summary" :total="total" :from="from" :to="to">
144
+ {{ from }}–{{ to }} of {{ total }}
145
+ </slot>
146
+ </div>
147
+ </template>
148
+ <div class="pui-index__pagination">
149
+ <div v-if="pageSizes.length" class="pui-index__page-size">
150
+ <Select
151
+ :label="pageSizeLabel"
152
+ :aria-label="pageSizeLabel"
153
+ :model-value="String(query.page_size)"
154
+ :options="pageSizeOptions"
155
+ @update:model-value="
156
+ updateCriteria({
157
+ page_size: Number($event),
158
+ })
159
+ "
160
+ />
161
+ </div>
162
+ <Pagination
163
+ :current-page="query.page"
164
+ :total-pages="totalPages"
165
+ @update:current-page="setPage($event)"
166
+ />
167
+ </div>
168
+ </PaginationFooter>
169
+ </div>
170
+ </template>
171
+
172
+ <script setup lang="ts" generic="T extends object">
173
+ import { computed, watch } from 'vue';
174
+ import DataTable from '../table/DataTable.vue';
175
+ import type { Column } from '../table/types.js';
176
+ import TextInput from '../../form/text-input/TextInput.vue';
177
+ import Select from '../../form/select/Select.vue';
178
+ import Alert from '../../feedback/alert/Alert.vue';
179
+ import Pagination from '../../navigation/pagination/Pagination.vue';
180
+ import PaginationFooter from '../../navigation/pagination/PaginationFooter.vue';
181
+ import type {
182
+ ApiError,
183
+ ListQuery,
184
+ ListLoader,
185
+ QueryOptions,
186
+ } from '../../../utils/cms/index.js';
187
+ import Button from '../../action/button/Button.vue';
188
+ import { generateId } from '../../../utils/a11y/id.js';
189
+ import { useIndex } from './useIndex.js';
190
+ import {
191
+ formatSort,
192
+ parseSort,
193
+ toggleSort,
194
+ } from '../../../utils/sort/index.js';
195
+
196
+ const props = withDefaults(
197
+ defineProps<{
198
+ /** Returns the matching page using the shared backend response contract. */
199
+ load: ListLoader<T>;
200
+ columns: Column[];
201
+ queryOptions: QueryOptions;
202
+ /** `module` is the page itself (flat, h1); `related` sits inside a record's detail page (one bordered region, h2). */
203
+ placement?: 'module' | 'related';
204
+ /** Synchronize list parameters with the current Vue Router route; a string prefixes every key (`contacts.page`). */
205
+ syncQuery?: boolean | string;
206
+ /** Delay in milliseconds before committing typed search text. */
207
+ searchDebounce?: number;
208
+ title?: string;
209
+ description?: string;
210
+ /** Accessible name for the table when there is no `title` to reference. */
211
+ tableLabel?: string;
212
+ searchable?: boolean;
213
+ searchLabel?: string;
214
+ searchPlaceholder?: string;
215
+ sortLabel?: string;
216
+ /** Labels for sort keys, including fields without a visible column. */
217
+ sortLabels?: Record<string, string>;
218
+ /** An empty array hides the page-size control. */
219
+ pageSizes?: number[];
220
+ pageSizeLabel?: string;
221
+ rowKey?: string;
222
+ selectable?: boolean;
223
+ selected?: (string | number)[];
224
+ clickable?: boolean;
225
+ }>(),
226
+ {
227
+ placement: 'module',
228
+ syncQuery: false,
229
+ searchDebounce: 250,
230
+ title: undefined,
231
+ description: undefined,
232
+ tableLabel: undefined,
233
+ searchable: true,
234
+ searchLabel: 'Search',
235
+ searchPlaceholder: undefined,
236
+ sortLabel: 'Sort by',
237
+ sortLabels: () => ({}),
238
+ pageSizes: () => [10, 20, 50],
239
+ pageSizeLabel: 'Rows per page',
240
+ rowKey: 'id',
241
+ selectable: false,
242
+ selected: () => [],
243
+ clickable: false,
244
+ },
245
+ );
246
+
247
+ const emit = defineEmits<{
248
+ 'update:selected': [keys: (string | number)[]];
249
+ 'row-click': [row: T];
250
+ }>();
251
+
252
+ const slots = defineSlots<{
253
+ actions?: (scope: {
254
+ reload: () => Promise<void>;
255
+ loading: boolean;
256
+ }) => unknown;
257
+ filters?: (scope: {
258
+ filters: ListQuery['filters'];
259
+ setFilters: (filters: ListQuery['filters']) => void;
260
+ setFilter: (name: string, value: string | undefined) => void;
261
+ }) => unknown;
262
+ selection?: (scope: {
263
+ selected: (string | number)[];
264
+ reload: () => Promise<void>;
265
+ loading: boolean;
266
+ }) => unknown;
267
+ error?: (scope: {
268
+ error: ApiError | null;
269
+ errors: string[];
270
+ retry: () => Promise<void>;
271
+ reset: () => Promise<void>;
272
+ invalidQuery: boolean;
273
+ }) => unknown;
274
+ empty?: () => unknown;
275
+ summary?: (scope: { total: number; from: number; to: number }) => unknown;
276
+ [name: `cell:${string}`]: (scope: { row: T; value: unknown }) => unknown;
277
+ }>();
278
+
279
+ const unsupportedSortColumns = computed(() =>
280
+ props.columns.filter(
281
+ (column) =>
282
+ column.sortable && !props.queryOptions.sorts.includes(column.key),
283
+ ),
284
+ );
285
+ function validateSortColumns(unsupported: Column[]) {
286
+ if (unsupported.length) {
287
+ throw new Error(
288
+ `[pui] Index sortable columns must be allowed by queryOptions.sorts: ${unsupported.map((column) => column.key).join(', ')}.`,
289
+ );
290
+ }
291
+ }
292
+ validateSortColumns(unsupportedSortColumns.value);
293
+ watch(unsupportedSortColumns, validateSortColumns);
294
+
295
+ const {
296
+ query,
297
+ search,
298
+ rows,
299
+ meta,
300
+ loading,
301
+ error,
302
+ errors,
303
+ invalidQuery,
304
+ updateCriteria,
305
+ setSearch,
306
+ setPage,
307
+ setFilters,
308
+ setFilter,
309
+ reload,
310
+ reset,
311
+ } = useIndex<T>(props);
312
+ defineExpose({ reload });
313
+ const titleId = generateId('index-title');
314
+ const total = computed(() => meta.value.total);
315
+ const showResults = computed(
316
+ () =>
317
+ !invalidQuery.value &&
318
+ (!errors.value.length || rows.value.length > 0 || loading.value),
319
+ );
320
+ const sortOptions = computed(() => [
321
+ { value: '', label: 'Default order' },
322
+ ...props.queryOptions.sorts.flatMap((key) => {
323
+ const label =
324
+ props.sortLabels[key] ??
325
+ props.columns.find((column) => column.key === key)?.label ??
326
+ key
327
+ .replaceAll('_', ' ')
328
+ .replace(/^./, (letter) => letter.toUpperCase());
329
+ return [
330
+ {
331
+ value: formatSort({ key, direction: 'asc' })!,
332
+ label: `${label} (ascending)`,
333
+ },
334
+ {
335
+ value: formatSort({ key, direction: 'desc' })!,
336
+ label: `${label} (descending)`,
337
+ },
338
+ ];
339
+ }),
340
+ ]);
341
+
342
+ const cellSlots = computed(() =>
343
+ props.columns
344
+ .map((column) => `cell:${column.key}` as const)
345
+ .filter((name) => slots[name]),
346
+ );
347
+ const totalPages = computed(() =>
348
+ Math.max(1, Math.ceil(total.value / query.value.page_size)),
349
+ );
350
+ const from = computed(() =>
351
+ rows.value.length ? (meta.value.page - 1) * meta.value.page_size + 1 : 0,
352
+ );
353
+ const to = computed(() =>
354
+ rows.value.length
355
+ ? Math.min(total.value, from.value + rows.value.length - 1)
356
+ : 0,
357
+ );
358
+ const pageSizeOptions = computed(() =>
359
+ [...new Set([...props.pageSizes, query.value.page_size])]
360
+ .filter(
361
+ (size) =>
362
+ Number.isInteger(size) &&
363
+ size > 0 &&
364
+ size <= (props.queryOptions.maxPageSize ?? 100),
365
+ )
366
+ .sort((a, b) => a - b)
367
+ .map((size) => ({ value: String(size), label: String(size) })),
368
+ );
369
+ </script>
370
+
371
+ <style>
372
+ @import './index.css';
373
+ </style>
@@ -0,0 +1,157 @@
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
+ }
50
+
51
+ .pui-index__toolbar {
52
+ display: flex;
53
+ flex-wrap: wrap;
54
+ align-items: end;
55
+ gap: var(--space-s);
56
+ }
57
+
58
+ .pui-index__search {
59
+ flex: 0 1 calc(var(--space-sidebar-width) * 1.5);
60
+ min-inline-size: min(100%, var(--space-sidebar-width));
61
+
62
+ @media (max-width: 48em) {
63
+ flex-grow: 1;
64
+ }
65
+ }
66
+
67
+ .pui-index__sort {
68
+ flex: 0 1 auto;
69
+ min-inline-size: 0;
70
+ max-inline-size: 100%;
71
+ }
72
+
73
+ .pui-index__results {
74
+ min-inline-size: 0;
75
+ }
76
+
77
+ .pui-index__table {
78
+ --pui-table-cell-padding-block: var(--space-2xs);
79
+ }
80
+
81
+ .pui-index__page-size > .pui-field {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: var(--space-xs);
85
+
86
+ & > .pui-field__label {
87
+ white-space: nowrap;
88
+ }
89
+ }
90
+
91
+ .pui-index__pagination {
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ justify-content: end;
96
+ gap: var(--space-s);
97
+
98
+ & > .pui-pagination-nav {
99
+ flex: 1;
100
+ }
101
+ }
102
+
103
+ /* ===== placements ===== */
104
+
105
+ /* Module: flat in the page; the table keeps DataTable's own border. The
106
+ selection region is always rendered, so it spaces itself only when filled. */
107
+ .pui-index[data-placement='module'] {
108
+ & .pui-index__toolbar,
109
+ & .pui-index__selection:not(:empty),
110
+ & .pui-index__error {
111
+ margin-block-end: var(--space-s);
112
+ }
113
+
114
+ & .pui-index__body > :last-child {
115
+ margin-block-end: 0;
116
+ }
117
+ }
118
+
119
+ /* Related: one bordered region; header, toolbar, table and footer are its bands. */
120
+ .pui-index[data-placement='related'] {
121
+ gap: 0;
122
+ background: var(--bg-clr-surface);
123
+ border: var(--stroke-sm) solid var(--border-clr-subtle);
124
+ border-radius: var(--radius-md);
125
+
126
+ & > .pui-index__header {
127
+ padding: var(--space-s);
128
+ }
129
+
130
+ & .pui-index__title {
131
+ font-size: var(--step-1);
132
+ font-weight: var(--fw-semibold);
133
+ letter-spacing: normal;
134
+ }
135
+
136
+ & .pui-index__toolbar {
137
+ padding: var(--space-s);
138
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
139
+ }
140
+
141
+ & .pui-index__selection:not(:empty),
142
+ & .pui-index__error {
143
+ padding: var(--space-s);
144
+ }
145
+
146
+ & .pui-index__table {
147
+ border: 0;
148
+ border-radius: 0;
149
+ }
150
+
151
+ & > .pui-index__footer {
152
+ border-block-start-color: var(--border-clr-subtle);
153
+ border-end-start-radius: inherit;
154
+ border-end-end-radius: inherit;
155
+ }
156
+ }
157
+ }