@pienter/ui 0.7.1 → 0.9.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 +108 -0
- package/CONVENTIONS.md +47 -29
- package/README.md +40 -0
- package/components/display/record-details/record-details.css +2 -2
- package/components/feedback/toast/Toast.vue +3 -4
- package/components/feedback/toast/ToastHost.vue +165 -0
- package/components/feedback/toast/toast.ts +116 -0
- package/components/feedback/toast/types.ts +48 -0
- package/components/form/block-editor/BlockEditor.vue +2 -1
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +11 -5
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/date-input/DateInput.vue +10 -2
- package/components/form/date-input/date-input.css +2 -17
- package/components/form/form/Form.vue +10 -9
- package/components/form/form/form.css +2 -22
- package/components/form/input-otp/InputOTP.vue +1 -1
- package/components/form/input-otp/input-otp.css +1 -1
- package/components/form/label/label.css +2 -11
- package/components/form/number-field/NumberField.vue +10 -2
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- package/components/form/select/Select.vue +13 -4
- package/components/form/select/select.css +6 -9
- package/components/form/slider/Slider.vue +1 -1
- package/components/form/switch/Switch.vue +1 -1
- package/components/form/tags-input/TagsInput.vue +17 -2
- package/components/form/tags-input/tags-input.css +16 -12
- package/components/form/text-input/TextInput.vue +10 -2
- package/components/form/text-input/text-input.css +10 -131
- package/components/form/textarea/Textarea.vue +10 -2
- package/components/form/textarea/textarea.css +3 -19
- package/components/layout/app-layout/AppLayout.vue +116 -0
- package/components/layout/app-layout/app-layout.css +115 -0
- package/components/layout/index/Index.vue +31 -11
- package/components/layout/index/index.css +55 -12
- package/components/layout/index/useIndex.ts +31 -14
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/navigation/breadcrumb/breadcrumb.css +6 -1
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useUrlTab.ts +38 -0
- package/package.json +7 -1
- package/styles/4-components/form-field.css +112 -0
- package/styles/4-components/index.css +1 -0
- package/styles/main.css +1 -0
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +21 -0
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
interface Options<T> {
|
|
21
21
|
load: ListLoader<T>;
|
|
22
22
|
queryOptions: QueryOptions;
|
|
23
|
-
|
|
23
|
+
/** `true` syncs the bare keys; a string syncs `<prefix>.<key>` instead. */
|
|
24
|
+
syncQuery: boolean | string;
|
|
24
25
|
searchDebounce: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -34,14 +35,21 @@ function isListParameter(key: string): boolean {
|
|
|
34
35
|
);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function
|
|
38
|
+
function listKey(key: string, prefix: string): string | undefined {
|
|
39
|
+
if (!key.startsWith(prefix)) return undefined;
|
|
40
|
+
const bare = key.slice(prefix.length);
|
|
41
|
+
return isListParameter(bare) ? bare : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function routeParams(values: LocationQuery, prefix: string): URLSearchParams {
|
|
38
45
|
const params = new URLSearchParams();
|
|
39
46
|
for (const [key, valuesForKey] of Object.entries(values)) {
|
|
40
|
-
|
|
47
|
+
const bare = listKey(key, prefix);
|
|
48
|
+
if (bare === undefined) continue;
|
|
41
49
|
for (const value of Array.isArray(valuesForKey)
|
|
42
50
|
? valuesForKey
|
|
43
51
|
: [valuesForKey])
|
|
44
|
-
params.append(
|
|
52
|
+
params.append(bare, value ?? '');
|
|
45
53
|
}
|
|
46
54
|
params.sort();
|
|
47
55
|
return params;
|
|
@@ -78,6 +86,11 @@ interface Work {
|
|
|
78
86
|
export function useIndex<T>(props: Options<T>) {
|
|
79
87
|
const route = inject(routeLocationKey, undefined);
|
|
80
88
|
const router = inject(routerKey, undefined);
|
|
89
|
+
const syncing = () => props.syncQuery !== false;
|
|
90
|
+
const prefix = () =>
|
|
91
|
+
typeof props.syncQuery === 'string' && props.syncQuery
|
|
92
|
+
? `${props.syncQuery}.`
|
|
93
|
+
: '';
|
|
81
94
|
const defaults = () =>
|
|
82
95
|
normalizeListQuery(
|
|
83
96
|
{
|
|
@@ -173,12 +186,12 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
173
186
|
}
|
|
174
187
|
|
|
175
188
|
const urlQuery = computed(() =>
|
|
176
|
-
|
|
189
|
+
syncing() && route ? routeParams(route.query, prefix()).toString() : '',
|
|
177
190
|
);
|
|
178
191
|
watch(
|
|
179
192
|
[
|
|
180
193
|
urlQuery,
|
|
181
|
-
() => (
|
|
194
|
+
() => (syncing() ? route?.path : ''),
|
|
182
195
|
() => props.syncQuery,
|
|
183
196
|
() => props.queryOptions,
|
|
184
197
|
() => props.load,
|
|
@@ -197,12 +210,12 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
197
210
|
navigation++;
|
|
198
211
|
if (!preserveSearch) cancelSearch();
|
|
199
212
|
cancelRequest();
|
|
200
|
-
if (
|
|
213
|
+
if (syncing() && (!route || !router))
|
|
201
214
|
throw new Error(
|
|
202
215
|
'Index sync-query requires an installed Vue Router.',
|
|
203
216
|
);
|
|
204
217
|
try {
|
|
205
|
-
const next =
|
|
218
|
+
const next = syncing()
|
|
206
219
|
? decodeListQuery(
|
|
207
220
|
new URLSearchParams(urlQuery.value),
|
|
208
221
|
props.queryOptions,
|
|
@@ -233,18 +246,22 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
233
246
|
work.finish();
|
|
234
247
|
return work.promise;
|
|
235
248
|
}
|
|
236
|
-
if (!
|
|
237
|
-
|
|
249
|
+
if (!syncing() || !route || !router) return accept(normalized, work);
|
|
250
|
+
const keyPrefix = prefix();
|
|
238
251
|
const values = { ...route.query };
|
|
239
252
|
for (const key of Object.keys(values))
|
|
240
|
-
if (
|
|
253
|
+
if (listKey(key, keyPrefix) !== undefined) delete values[key];
|
|
241
254
|
for (const [key, value] of encodeListQuery(normalized))
|
|
242
|
-
values[key] = value;
|
|
255
|
+
values[keyPrefix + key] = value;
|
|
243
256
|
query.value = normalized;
|
|
244
257
|
search.value = normalized.search;
|
|
245
258
|
const attempt = ++navigation;
|
|
246
259
|
const path = route.path;
|
|
247
|
-
pendingUrl = {
|
|
260
|
+
pendingUrl = {
|
|
261
|
+
path,
|
|
262
|
+
query: routeParams(values, keyPrefix).toString(),
|
|
263
|
+
work,
|
|
264
|
+
};
|
|
248
265
|
loading.value = true;
|
|
249
266
|
void (async () => {
|
|
250
267
|
try {
|
|
@@ -285,7 +302,7 @@ export function useIndex<T>(props: Options<T>) {
|
|
|
285
302
|
cancelRequest();
|
|
286
303
|
try {
|
|
287
304
|
query.value = decodeListQuery(
|
|
288
|
-
routeParams(route!.query),
|
|
305
|
+
routeParams(route!.query, prefix()),
|
|
289
306
|
props.queryOptions,
|
|
290
307
|
);
|
|
291
308
|
if (!preserveSearch) search.value = query.value.search;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
:selectable="selectable"
|
|
11
11
|
:clickable="clickable"
|
|
12
12
|
:all-selected="allSelected"
|
|
13
|
+
:label="label"
|
|
14
|
+
:labelledby="labelledby"
|
|
13
15
|
@toggle-all="toggleAll"
|
|
14
16
|
@sort="(key) => emit('sort', key)"
|
|
15
17
|
>
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
class="pui-table__check"
|
|
27
29
|
type="checkbox"
|
|
28
30
|
:checked="isSelected(keyOf(row))"
|
|
29
|
-
aria-label="
|
|
31
|
+
:aria-label="rowLabel(row)"
|
|
30
32
|
@click.stop
|
|
31
33
|
@change="toggle(keyOf(row))"
|
|
32
34
|
/>
|
|
@@ -78,6 +80,10 @@ const props = withDefaults(
|
|
|
78
80
|
sort?: SortState;
|
|
79
81
|
/** Frosts and disables the body while the rows are stale, and suppresses the empty state. */
|
|
80
82
|
loading?: boolean;
|
|
83
|
+
/** Accessible name, rendered as a visually hidden `<caption>`. */
|
|
84
|
+
label?: string;
|
|
85
|
+
/** `id` of a visible heading that names the table; wins over `label`. */
|
|
86
|
+
labelledby?: string;
|
|
81
87
|
}>(),
|
|
82
88
|
{
|
|
83
89
|
rowKey: 'id',
|
|
@@ -86,6 +92,8 @@ const props = withDefaults(
|
|
|
86
92
|
clickable: false,
|
|
87
93
|
sort: null,
|
|
88
94
|
loading: false,
|
|
95
|
+
label: undefined,
|
|
96
|
+
labelledby: undefined,
|
|
89
97
|
},
|
|
90
98
|
);
|
|
91
99
|
|
|
@@ -103,6 +111,11 @@ function cellValue(row: T, key: string): unknown {
|
|
|
103
111
|
function keyOf(row: T): string | number {
|
|
104
112
|
return cellValue(row, props.rowKey) as string | number;
|
|
105
113
|
}
|
|
114
|
+
function rowLabel(row: T): string {
|
|
115
|
+
const first = props.columns[0];
|
|
116
|
+
const value = first ? String(cellValue(row, first.key) ?? '').trim() : '';
|
|
117
|
+
return value ? `Select ${value}` : 'Select row';
|
|
118
|
+
}
|
|
106
119
|
function isSelected(key: string | number): boolean {
|
|
107
120
|
return props.selected.includes(key);
|
|
108
121
|
}
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
class="pui-table"
|
|
9
9
|
:style="{ '--pui-table-cols': cols }"
|
|
10
10
|
:aria-busy="loading || undefined"
|
|
11
|
+
:aria-labelledby="labelledby"
|
|
11
12
|
>
|
|
13
|
+
<caption v-if="label" class="u-visually-hidden">
|
|
14
|
+
{{
|
|
15
|
+
label
|
|
16
|
+
}}
|
|
17
|
+
</caption>
|
|
12
18
|
<thead class="pui-table__head">
|
|
13
19
|
<tr class="pui-table__row" data-head>
|
|
14
20
|
<th
|
|
@@ -104,6 +110,10 @@ const props = withDefaults(
|
|
|
104
110
|
sort?: SortState;
|
|
105
111
|
/** Frosts and disables the body while the rows are stale, leaving the header usable. */
|
|
106
112
|
loading?: boolean;
|
|
113
|
+
/** Accessible name, rendered as a visually hidden `<caption>`. */
|
|
114
|
+
label?: string;
|
|
115
|
+
/** `id` of a visible heading that names the table; wins over `label`. */
|
|
116
|
+
labelledby?: string;
|
|
107
117
|
}>(),
|
|
108
118
|
{
|
|
109
119
|
selectable: false,
|
|
@@ -111,6 +121,8 @@ const props = withDefaults(
|
|
|
111
121
|
allSelected: false,
|
|
112
122
|
sort: null,
|
|
113
123
|
loading: false,
|
|
124
|
+
label: undefined,
|
|
125
|
+
labelledby: undefined,
|
|
114
126
|
},
|
|
115
127
|
);
|
|
116
128
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
display: flex;
|
|
4
4
|
align-items: center;
|
|
5
5
|
justify-content: space-between;
|
|
6
|
-
gap: var(--space-
|
|
6
|
+
gap: var(--space-s);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
.pui-breadcrumb__trail {
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
color: var(--text-clr-base);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
.pui-breadcrumb__link:focus-visible {
|
|
45
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
46
|
+
outline-offset: var(--outline-offset);
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
.pui-breadcrumb__sep {
|
|
45
50
|
color: var(--border-clr-base);
|
|
46
51
|
}
|
|
@@ -290,7 +290,9 @@
|
|
|
290
290
|
flex-direction: column;
|
|
291
291
|
justify-content: center;
|
|
292
292
|
gap: var(--space-3xs);
|
|
293
|
-
|
|
293
|
+
/* The panel's own inline padding already holds the focus ring; any
|
|
294
|
+
more and a nine-character caption ("Companies") breaks mid-word. */
|
|
295
|
+
padding-inline: 0;
|
|
294
296
|
text-align: center;
|
|
295
297
|
}
|
|
296
298
|
|
|
@@ -425,21 +427,6 @@
|
|
|
425
427
|
padding-inline-start: var(--space-2xs);
|
|
426
428
|
}
|
|
427
429
|
|
|
428
|
-
/* It travels, so it needs its own reduced-motion escape. */
|
|
429
|
-
@media (prefers-reduced-motion: reduce) {
|
|
430
|
-
.pui-sidebar[data-state='collapsed']
|
|
431
|
-
.pui-sidebar__group[data-submenu-state]
|
|
432
|
-
> .pui-sidebar__group-items {
|
|
433
|
-
transition: none;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
.pui-sidebar[data-state='collapsed']
|
|
437
|
-
.pui-sidebar__group[data-submenu-state='closed']
|
|
438
|
-
> .pui-sidebar__group-items {
|
|
439
|
-
transform: none;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
430
|
/* Mobile drawer mode — below the breakpoint, the column transforms into a fixed-overlay drawer */
|
|
444
431
|
@media (max-width: 767px) {
|
|
445
432
|
.pui-sidebar,
|
|
@@ -568,4 +555,24 @@
|
|
|
568
555
|
background: transparent;
|
|
569
556
|
}
|
|
570
557
|
}
|
|
558
|
+
|
|
559
|
+
/* Everything that travels or fades. After the drawer block: a media query
|
|
560
|
+
grants no extra specificity, so order decides. */
|
|
561
|
+
@media (prefers-reduced-motion: reduce) {
|
|
562
|
+
.pui-sidebar,
|
|
563
|
+
.pui-sidebar__panel,
|
|
564
|
+
.pui-sidebar__backdrop,
|
|
565
|
+
.pui-sidebar__group-caret,
|
|
566
|
+
.pui-sidebar[data-state='collapsed']
|
|
567
|
+
.pui-sidebar__group[data-submenu-state]
|
|
568
|
+
> .pui-sidebar__group-items {
|
|
569
|
+
transition: none;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.pui-sidebar[data-state='collapsed']
|
|
573
|
+
.pui-sidebar__group[data-submenu-state='closed']
|
|
574
|
+
> .pui-sidebar__group-items {
|
|
575
|
+
transform: none;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
571
578
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.pui-tabs {
|
|
3
3
|
display: flex;
|
|
4
4
|
gap: var(--space-s);
|
|
5
|
-
border-block-end: var(--stroke-sm) solid var(--border-clr-
|
|
5
|
+
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
6
6
|
}
|
|
7
7
|
.pui-tabs__item {
|
|
8
8
|
appearance: none;
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
font-weight: var(--fw-semibold);
|
|
15
15
|
color: var(--text-clr-muted);
|
|
16
16
|
cursor: pointer;
|
|
17
|
-
border-block-end:
|
|
18
|
-
margin-block-end: -
|
|
19
|
-
transition: color
|
|
17
|
+
border-block-end: var(--stroke-lg) solid transparent;
|
|
18
|
+
margin-block-end: calc(-1 * var(--stroke-sm));
|
|
19
|
+
transition: color var(--duration-fast) var(--ease-base);
|
|
20
20
|
}
|
|
21
21
|
.pui-tabs__item[aria-selected='true'] {
|
|
22
22
|
color: var(--text-clr-base);
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
color: var(--text-clr-base);
|
|
27
27
|
}
|
|
28
28
|
.pui-tabs__item:focus-visible {
|
|
29
|
-
outline:
|
|
30
|
-
outline-offset:
|
|
29
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
30
|
+
outline-offset: var(--outline-offset);
|
|
31
31
|
}
|
|
32
32
|
.pui-tabs__item:disabled {
|
|
33
|
-
opacity:
|
|
33
|
+
opacity: var(--opacity-disabled);
|
|
34
34
|
cursor: not-allowed;
|
|
35
35
|
}
|
|
36
36
|
.pui-tabs__item:disabled:hover {
|
|
@@ -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.9.0",
|
|
4
4
|
"description": "Shared Pienter UI components, styles, icons, and browser utilities.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"./components/Skeleton.vue": "./components/feedback/skeleton/Skeleton.vue",
|
|
42
42
|
"./components/Spinner.vue": "./components/feedback/spinner/Spinner.vue",
|
|
43
43
|
"./components/Toast.vue": "./components/feedback/toast/Toast.vue",
|
|
44
|
+
"./components/ToastHost.vue": "./components/feedback/toast/ToastHost.vue",
|
|
45
|
+
"./components/Toast.types": "./components/feedback/toast/types.ts",
|
|
44
46
|
"./components/Checkbox.vue": "./components/form/checkbox/Checkbox.vue",
|
|
45
47
|
"./components/Combobox.vue": "./components/form/combobox/Combobox.vue",
|
|
46
48
|
"./components/DateInput.vue": "./components/form/date-input/DateInput.vue",
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
"./components/Accordion.vue": "./components/layout/accordion/Accordion.vue",
|
|
61
63
|
"./components/Card.vue": "./components/layout/card/Card.vue",
|
|
62
64
|
"./components/Collapsible.vue": "./components/layout/collapsible/Collapsible.vue",
|
|
65
|
+
"./components/AppLayout.vue": "./components/layout/app-layout/AppLayout.vue",
|
|
63
66
|
"./components/Separator.vue": "./components/layout/separator/Separator.vue",
|
|
64
67
|
"./components/Table.vue": "./components/layout/table/Table.vue",
|
|
65
68
|
"./components/TableRow.vue": "./components/layout/table/TableRow.vue",
|
|
@@ -84,10 +87,13 @@
|
|
|
84
87
|
"./composables/useMenu": "./composables/useMenu.ts",
|
|
85
88
|
"./composables/usePopover": "./composables/usePopover.ts",
|
|
86
89
|
"./composables/useUrlSort": "./composables/useUrlSort.ts",
|
|
90
|
+
"./composables/useUrlTab": "./composables/useUrlTab.ts",
|
|
87
91
|
"./icons": "./icons/index.ts",
|
|
88
92
|
"./icons/*": "./icons/*",
|
|
89
93
|
"./utils": "./utils/index.ts",
|
|
90
94
|
"./utils/*": "./utils/*",
|
|
95
|
+
"./toast": "./components/feedback/toast/toast.ts",
|
|
96
|
+
"./utils/a11y": "./utils/a11y/index.ts",
|
|
91
97
|
"./components/Index.vue": "./components/layout/index/Index.vue",
|
|
92
98
|
"./components/RecordForm.vue": "./components/form/record-form/RecordForm.vue",
|
|
93
99
|
"./components/RecordForm.types": "./components/form/record-form/types.ts",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* The form-primitive scaffold shared by every form control: the
|
|
3
|
+
* `.pui-field` wrapper, its label / hint / required marker, and the
|
|
4
|
+
* `.pui-input` / `.pui-textarea` control surface. Loaded with the
|
|
5
|
+
* styles entry so it does not depend on which component is on the page. */
|
|
6
|
+
|
|
7
|
+
.pui-field {
|
|
8
|
+
display: grid;
|
|
9
|
+
align-content: start;
|
|
10
|
+
gap: var(--space-2xs);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Zero the UA fieldset chrome (groove border, padding, and the
|
|
14
|
+
* `min-inline-size: min-content` that stops it shrinking) so a
|
|
15
|
+
* grouped control can host the grid scaffold. */
|
|
16
|
+
.pui-field:where(fieldset) {
|
|
17
|
+
border: 0;
|
|
18
|
+
padding: 0;
|
|
19
|
+
margin: 0;
|
|
20
|
+
min-inline-size: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* A legend floats and pads itself inside a grid fieldset; reset it to
|
|
24
|
+
* an ordinary grid item so the row gap governs label spacing. */
|
|
25
|
+
.pui-field__label:where(legend) {
|
|
26
|
+
padding: 0;
|
|
27
|
+
float: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* `data-layout="inline"`: control first in DOM order, label beside it,
|
|
31
|
+
* hint and errors below spanning both columns. */
|
|
32
|
+
.pui-field[data-layout='inline'] {
|
|
33
|
+
grid-template-columns: max-content 1fr;
|
|
34
|
+
column-gap: var(--space-xs);
|
|
35
|
+
align-items: center;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.pui-field[data-layout='inline'] .pui-field__label {
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
user-select: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.pui-field[data-layout='inline'] .pui-field__hint {
|
|
44
|
+
grid-column: 1 / -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.pui-field__label {
|
|
48
|
+
margin-block: 0;
|
|
49
|
+
font-size: var(--step--1);
|
|
50
|
+
font-weight: var(--fw-bold);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.pui-field__required {
|
|
54
|
+
margin-inline-start: var(--space-3xs);
|
|
55
|
+
color: var(--text-clr-danger);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.pui-field__hint {
|
|
59
|
+
font-size: var(--step--2);
|
|
60
|
+
color: var(--text-clr-muted);
|
|
61
|
+
margin: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.pui-field[data-status='error'] .pui-field__hint {
|
|
65
|
+
color: var(--text-clr-danger);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.pui-field[data-status='success'] .pui-field__hint {
|
|
69
|
+
color: var(--text-clr-success);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.pui-input,
|
|
73
|
+
.pui-textarea {
|
|
74
|
+
width: 100%;
|
|
75
|
+
padding: 0.65em 0.85em;
|
|
76
|
+
font: inherit;
|
|
77
|
+
background: var(--bg-clr-surface);
|
|
78
|
+
border: var(--stroke-sm) solid var(--border-clr-input);
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
color: var(--text-clr-base);
|
|
81
|
+
transition: border-color var(--duration-fast) var(--ease-base);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.pui-textarea {
|
|
85
|
+
min-height: 6rem;
|
|
86
|
+
resize: vertical;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.pui-input:focus-visible,
|
|
90
|
+
.pui-textarea:focus-visible {
|
|
91
|
+
border-color: var(--border-clr-brand);
|
|
92
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
93
|
+
outline-offset: var(--outline-offset);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.pui-input:disabled,
|
|
97
|
+
.pui-textarea:disabled {
|
|
98
|
+
background: var(--bg-clr-surface-2);
|
|
99
|
+
color: var(--text-clr-muted);
|
|
100
|
+
cursor: not-allowed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.pui-field[data-status='error'] .pui-input,
|
|
104
|
+
.pui-field[data-status='error'] .pui-textarea {
|
|
105
|
+
border-color: var(--border-clr-danger);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.pui-field[data-status='success'] .pui-input,
|
|
109
|
+
.pui-field[data-status='success'] .pui-textarea {
|
|
110
|
+
border-color: var(--border-clr-success);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url('./form-field.css');
|
package/styles/main.css
CHANGED
package/utils/a11y/index.ts
CHANGED
|
@@ -6,4 +6,8 @@ export {
|
|
|
6
6
|
type FocusTrap,
|
|
7
7
|
} from './focus.js';
|
|
8
8
|
export { onKeyboardNav, type KeyboardNavHandlers } from './keyboard.js';
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
announce,
|
|
11
|
+
ensureLiveRegion,
|
|
12
|
+
type LiveRegionPriority,
|
|
13
|
+
} from './live-region.js';
|
|
@@ -2,7 +2,8 @@ export type LiveRegionPriority = 'polite' | 'assertive';
|
|
|
2
2
|
|
|
3
3
|
const REGION_ID = 'pienter-ui-live-region';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Creates the shared live region if missing; call on mount so the first `announce()` is not dropped. */
|
|
6
|
+
export function ensureLiveRegion(): HTMLElement {
|
|
6
7
|
let region = document.getElementById(REGION_ID);
|
|
7
8
|
if (!region) {
|
|
8
9
|
region = document.createElement('div');
|
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
|
+
}
|