@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.
- package/CHANGELOG.md +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/pagination/PaginationFooter.astro +0 -24
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/menu.ts +0 -255
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- 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
|
|
9
|
-
*
|
|
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 (
|
|
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
|
|
@@ -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
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|