@pienter/ui 0.8.0 → 0.10.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.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -0
- package/components/layout/index/Index.vue +9 -18
- package/components/layout/index/index.css +1 -38
- package/components/layout/page-header/PageHeader.vue +49 -0
- package/components/layout/page-header/page-header.css +44 -0
- package/composables/useUrlTab.ts +38 -0
- package/package.json +3 -1
- package/utils/cms/index.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.10.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Index renders its title, description and actions through the new
|
|
10
|
+
`PageHeader`, so the `pui-index__header`, `pui-index__heading`,
|
|
11
|
+
`pui-index__title`, `pui-index__description` and `pui-index__actions`
|
|
12
|
+
classes are gone. They were never documented as public API; a consumer
|
|
13
|
+
style that targeted them moves to `pui-page-header` and its
|
|
14
|
+
`__heading`, `__title`, `__description` and `__actions` elements. Markup,
|
|
15
|
+
spacing and type are unchanged in both placements.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `PageHeader` at `@pienter/ui/components/PageHeader.vue`: the title,
|
|
20
|
+
description and actions band above a module index or a record page,
|
|
21
|
+
extracted from Index so detail and form pages no longer hand-roll the same
|
|
22
|
+
header. `title` and `description` are plain strings, `level` (`1` | `2` |
|
|
23
|
+
`3`, default `1`) picks the heading element and sizes levels 2 and 3 as
|
|
24
|
+
section headings, `title-id` sets the title's `id` for `aria-labelledby`,
|
|
25
|
+
and `#actions` fills the trailing row. It renders nothing when it has no
|
|
26
|
+
title, description or actions. Index uses it at level 1 in module placement
|
|
27
|
+
and level 2 in related placement.
|
|
28
|
+
|
|
29
|
+
## 0.9.0 - 2026-09-15
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `useUrlTab(keys, defaultKey, param = 'tab')` at
|
|
34
|
+
`@pienter/ui/composables/useUrlTab` keeps the active `Tabs` key in the URL
|
|
35
|
+
query, the single source of truth, so a shared link or a reload opens the
|
|
36
|
+
same tab. It returns a writable computed for `v-model`: the default key
|
|
37
|
+
drops the parameter, an unknown value falls back to it, and writes go
|
|
38
|
+
through `router.replace` so tab switches stay out of the back stack and
|
|
39
|
+
other query parameters survive. Requires the optional `vue-router` peer,
|
|
40
|
+
like `useUrlSort`.
|
|
41
|
+
- `formFailure(cause, fallback)` in `@pienter/ui/utils/cms` turns a caught
|
|
42
|
+
save error into RecordForm's `{ issues, errors }`: a value carrying the
|
|
43
|
+
`ErrorResponse` envelope contributes its field issues and leaves `errors`
|
|
44
|
+
empty; otherwise `errors` holds the caught `Error` message or `fallback`.
|
|
45
|
+
|
|
5
46
|
## 0.8.0 - 2026-09-15
|
|
6
47
|
|
|
7
48
|
### Breaking
|
package/README.md
CHANGED
|
@@ -92,6 +92,10 @@ declarations, endpoint functions and saving. Use RecordForm’s `fields` slot an
|
|
|
92
92
|
RecordFields to put groups in sidebars and tab panels while retaining one form.
|
|
93
93
|
BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
|
|
94
94
|
provides drag-and-drop, keyboard movement, add/remove and collapse controls.
|
|
95
|
+
`useUrlTab` from `@pienter/ui/composables/useUrlTab` keeps the active tab key in
|
|
96
|
+
the query string for `v-model` on Tabs, and `formFailure(cause, fallback)` from
|
|
97
|
+
`@pienter/ui/utils/cms` turns a caught save error into RecordForm's `issues` and
|
|
98
|
+
`errors`.
|
|
95
99
|
|
|
96
100
|
The [CMS guide](../../docs/cms/README.md) contains working examples. The
|
|
97
101
|
[backend contract](../../docs/cms/backend-contract.md) defines query parameters,
|
|
@@ -4,26 +4,16 @@
|
|
|
4
4
|
:data-placement="placement"
|
|
5
5
|
:data-state="loading ? 'loading' : undefined"
|
|
6
6
|
>
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
<PageHeader
|
|
8
|
+
:title="title"
|
|
9
|
+
:description="description"
|
|
10
|
+
:level="placement === 'related' ? 2 : 1"
|
|
11
|
+
:title-id="titleId"
|
|
10
12
|
>
|
|
11
|
-
<
|
|
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">
|
|
13
|
+
<template v-if="$slots.actions" #actions>
|
|
24
14
|
<slot name="actions" :reload="reload" :loading="loading" />
|
|
25
|
-
</
|
|
26
|
-
</
|
|
15
|
+
</template>
|
|
16
|
+
</PageHeader>
|
|
27
17
|
|
|
28
18
|
<div class="pui-index__body">
|
|
29
19
|
<div
|
|
@@ -172,6 +162,7 @@
|
|
|
172
162
|
<script setup lang="ts" generic="T extends object">
|
|
173
163
|
import { computed, watch } from 'vue';
|
|
174
164
|
import DataTable from '../table/DataTable.vue';
|
|
165
|
+
import PageHeader from '../page-header/PageHeader.vue';
|
|
175
166
|
import type { Column } from '../table/types.js';
|
|
176
167
|
import TextInput from '../../form/text-input/TextInput.vue';
|
|
177
168
|
import Select from '../../form/select/Select.vue';
|
|
@@ -6,37 +6,6 @@
|
|
|
6
6
|
color: var(--text-clr-base);
|
|
7
7
|
}
|
|
8
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
9
|
.pui-index__selection {
|
|
41
10
|
display: flex;
|
|
42
11
|
flex-wrap: wrap;
|
|
@@ -123,16 +92,10 @@
|
|
|
123
92
|
border: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
124
93
|
border-radius: var(--radius-md);
|
|
125
94
|
|
|
126
|
-
& > .pui-
|
|
95
|
+
& > .pui-page-header {
|
|
127
96
|
padding: var(--space-s);
|
|
128
97
|
}
|
|
129
98
|
|
|
130
|
-
& .pui-index__title {
|
|
131
|
-
font-size: var(--step-1);
|
|
132
|
-
font-weight: var(--fw-semibold);
|
|
133
|
-
letter-spacing: normal;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
99
|
& .pui-index__toolbar {
|
|
137
100
|
padding: var(--space-s);
|
|
138
101
|
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header
|
|
3
|
+
v-if="title || description || $slots.actions"
|
|
4
|
+
class="pui-page-header"
|
|
5
|
+
>
|
|
6
|
+
<div v-if="title || description" class="pui-page-header__heading">
|
|
7
|
+
<component
|
|
8
|
+
:is="`h${level}`"
|
|
9
|
+
v-if="title"
|
|
10
|
+
:id="titleId"
|
|
11
|
+
class="pui-page-header__title"
|
|
12
|
+
>{{ title }}</component
|
|
13
|
+
>
|
|
14
|
+
<p v-if="description" class="pui-page-header__description">
|
|
15
|
+
{{ description }}
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
<div v-if="$slots.actions" class="pui-page-header__actions">
|
|
19
|
+
<slot name="actions" />
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
withDefaults(
|
|
26
|
+
defineProps<{
|
|
27
|
+
title?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Heading level of the title: `1` for a page, `2` or `3` for a band inside one. */
|
|
30
|
+
level?: 1 | 2 | 3;
|
|
31
|
+
/** `id` for the title element, so a region can reference it through `aria-labelledby`. */
|
|
32
|
+
titleId?: string;
|
|
33
|
+
}>(),
|
|
34
|
+
{
|
|
35
|
+
title: undefined,
|
|
36
|
+
description: undefined,
|
|
37
|
+
level: 1,
|
|
38
|
+
titleId: undefined,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
defineSlots<{
|
|
43
|
+
actions?: () => unknown;
|
|
44
|
+
}>();
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
@import './page-header.css';
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-page-header {
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
5
|
+
align-items: center;
|
|
6
|
+
justify-content: space-between;
|
|
7
|
+
gap: var(--space-s);
|
|
8
|
+
padding-block-end: var(--space-s);
|
|
9
|
+
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.pui-page-header__heading {
|
|
13
|
+
display: grid;
|
|
14
|
+
gap: var(--space-3xs);
|
|
15
|
+
min-inline-size: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.pui-page-header__title {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-family: var(--ff-display);
|
|
21
|
+
font-size: var(--step-2);
|
|
22
|
+
font-weight: var(--fw-display);
|
|
23
|
+
letter-spacing: var(--ls-display);
|
|
24
|
+
|
|
25
|
+
&:is(h2, h3) {
|
|
26
|
+
font-size: var(--step-1);
|
|
27
|
+
font-weight: var(--fw-semibold);
|
|
28
|
+
letter-spacing: normal;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.pui-page-header__description {
|
|
33
|
+
margin: 0;
|
|
34
|
+
font-size: var(--step--1);
|
|
35
|
+
color: var(--text-clr-muted);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.pui-page-header__actions {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-wrap: wrap;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: var(--space-xs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { computed, type WritableComputedRef } from 'vue';
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keeps the active `Tabs` key in the URL query, the single source of truth, so a shared
|
|
6
|
+
* link or a reload opens the same tab. Bind with `v-model`. The default key drops the
|
|
7
|
+
* parameter and an unknown value falls back to it; writes go through `router.replace`,
|
|
8
|
+
* so tab switches stay out of the back stack and other query parameters survive.
|
|
9
|
+
* Requires the optional `vue-router` peer dependency.
|
|
10
|
+
*
|
|
11
|
+
* @param keys - the tab keys the view renders.
|
|
12
|
+
* @param defaultKey - key used when the parameter is absent or unknown.
|
|
13
|
+
* @param param - query parameter name; defaults to `'tab'`.
|
|
14
|
+
*/
|
|
15
|
+
export function useUrlTab(
|
|
16
|
+
keys: readonly string[],
|
|
17
|
+
defaultKey: string,
|
|
18
|
+
param = 'tab',
|
|
19
|
+
): WritableComputedRef<string> {
|
|
20
|
+
const route = useRoute();
|
|
21
|
+
const router = useRouter();
|
|
22
|
+
|
|
23
|
+
return computed<string>({
|
|
24
|
+
get: () => {
|
|
25
|
+
const raw = route.query[param];
|
|
26
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
27
|
+
return typeof value === 'string' && keys.includes(value)
|
|
28
|
+
? value
|
|
29
|
+
: defaultKey;
|
|
30
|
+
},
|
|
31
|
+
set: (key) => {
|
|
32
|
+
const query = { ...route.query };
|
|
33
|
+
if (key === defaultKey) delete query[param];
|
|
34
|
+
else query[param] = key;
|
|
35
|
+
void router.replace({ query });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pienter/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Shared Pienter UI components, styles, icons, and browser utilities.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"./components/Card.vue": "./components/layout/card/Card.vue",
|
|
64
64
|
"./components/Collapsible.vue": "./components/layout/collapsible/Collapsible.vue",
|
|
65
65
|
"./components/AppLayout.vue": "./components/layout/app-layout/AppLayout.vue",
|
|
66
|
+
"./components/PageHeader.vue": "./components/layout/page-header/PageHeader.vue",
|
|
66
67
|
"./components/Separator.vue": "./components/layout/separator/Separator.vue",
|
|
67
68
|
"./components/Table.vue": "./components/layout/table/Table.vue",
|
|
68
69
|
"./components/TableRow.vue": "./components/layout/table/TableRow.vue",
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"./composables/useMenu": "./composables/useMenu.ts",
|
|
88
89
|
"./composables/usePopover": "./composables/usePopover.ts",
|
|
89
90
|
"./composables/useUrlSort": "./composables/useUrlSort.ts",
|
|
91
|
+
"./composables/useUrlTab": "./composables/useUrlTab.ts",
|
|
90
92
|
"./icons": "./icons/index.ts",
|
|
91
93
|
"./icons/*": "./icons/*",
|
|
92
94
|
"./utils": "./utils/index.ts",
|
package/utils/cms/index.ts
CHANGED
|
@@ -281,3 +281,24 @@ export function errorsFor(
|
|
|
281
281
|
)
|
|
282
282
|
.flatMap((issue) => issue.messages);
|
|
283
283
|
}
|
|
284
|
+
|
|
285
|
+
export interface FormFailure {
|
|
286
|
+
issues: ValidationIssue[];
|
|
287
|
+
errors: string[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Turns a failed save into RecordForm's `issues` and `errors` props. A caught value
|
|
292
|
+
* carrying the `ErrorResponse` envelope (`{ error: ApiError }`) contributes its field
|
|
293
|
+
* issues; without issues, `errors` holds the caught `Error` message, falling back to
|
|
294
|
+
* `fallback`.
|
|
295
|
+
*/
|
|
296
|
+
export function formFailure(cause: unknown, fallback: string): FormFailure {
|
|
297
|
+
const error = (cause as Partial<ErrorResponse> | null | undefined)?.error;
|
|
298
|
+
const issues = Array.isArray(error?.issues) ? error.issues : [];
|
|
299
|
+
if (issues.length) return { issues, errors: [] };
|
|
300
|
+
return {
|
|
301
|
+
issues: [],
|
|
302
|
+
errors: [cause instanceof Error ? cause.message : fallback],
|
|
303
|
+
};
|
|
304
|
+
}
|