@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 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
- <header
8
- v-if="title || description || $slots.actions"
9
- class="pui-index__header"
7
+ <PageHeader
8
+ :title="title"
9
+ :description="description"
10
+ :level="placement === 'related' ? 2 : 1"
11
+ :title-id="titleId"
10
12
  >
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">
13
+ <template v-if="$slots.actions" #actions>
24
14
  <slot name="actions" :reload="reload" :loading="loading" />
25
- </div>
26
- </header>
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-index__header {
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.8.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",
@@ -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
+ }