@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
|
@@ -1,143 +1,22 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
/*
|
|
3
|
-
|
|
4
|
-
* Default layout (`data-layout="stacked"` or unset): label above the
|
|
5
|
-
* control, hint and errors below — one column, vertical flow. Used by
|
|
6
|
-
* TextInput, Textarea, Select, NumberField, DateInput.
|
|
7
|
-
*
|
|
8
|
-
* Inline layout (`data-layout="inline"`): control left, label right
|
|
9
|
-
* (next to the control), hint and errors below the row spanning both
|
|
10
|
-
* columns. Used by compact form primitives (Switch, Checkbox,
|
|
11
|
-
* RadioGroup individual items) where the control is small enough to
|
|
12
|
-
* sit beside its label without the stacked label-above pattern. The
|
|
13
|
-
* control is the FIRST DOM child so a label-following-input source
|
|
14
|
-
* order can be styled via a grid template.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
.pui-field {
|
|
18
|
-
display: grid;
|
|
19
|
-
align-content: start;
|
|
20
|
-
gap: var(--space-2xs);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/* Zero <fieldset>'s user-agent defaults so it can host the pui-field grid
|
|
24
|
-
* scaffold cleanly. Without this, browsers paint a 2px groove border, add
|
|
25
|
-
* default block padding, and apply `min-inline-size: min-content` (which
|
|
26
|
-
* forces the fieldset to never shrink below its widest child). RadioGroup
|
|
27
|
-
* is the first form-primitive built on <fieldset>; future grouped-control
|
|
28
|
-
* primitives (checkbox groups, date-range pickers) reuse this carve-out. */
|
|
29
|
-
.pui-field:where(fieldset) {
|
|
30
|
-
border: 0;
|
|
31
|
-
padding: 0;
|
|
32
|
-
margin: 0;
|
|
33
|
-
min-inline-size: 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/* <legend> default rendering ("float" anchor, automatic padding-inline)
|
|
37
|
-
* misaligns inside a grid <fieldset>; zero those so the legend behaves
|
|
38
|
-
* like a normal block grid item, allowing `.pui-field`'s row gap to govern
|
|
39
|
-
* the label-to-control spacing. */
|
|
40
|
-
.pui-field__label:where(legend) {
|
|
41
|
-
padding: 0;
|
|
42
|
-
float: none;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
.pui-field[data-layout='inline'] {
|
|
46
|
-
grid-template-columns: max-content 1fr;
|
|
47
|
-
column-gap: var(--space-xs);
|
|
48
|
-
align-items: center;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.pui-field[data-layout='inline'] .pui-field__label {
|
|
52
|
-
/* Pointer affordance for clickable label paired with a small control. */
|
|
53
|
-
cursor: pointer;
|
|
54
|
-
user-select: none;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.pui-field[data-layout='inline'] .pui-field__hint {
|
|
58
|
-
/* Hint and errors span both columns so they sit below the row. */
|
|
59
|
-
grid-column: 1 / -1;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.pui-field__label {
|
|
63
|
-
margin-block: 0;
|
|
64
|
-
font-size: var(--step--1);
|
|
65
|
-
font-weight: var(--fw-bold);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
.pui-field__hint {
|
|
69
|
-
font-size: var(--step--2);
|
|
70
|
-
color: var(--text-clr-muted);
|
|
71
|
-
margin: 0;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.pui-field[data-status='error'] .pui-field__hint {
|
|
75
|
-
color: var(--text-clr-danger);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.pui-field[data-status='success'] .pui-field__hint {
|
|
79
|
-
color: var(--text-clr-success);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/* ===== pui-input / pui-textarea (actual controls) ===== */
|
|
83
|
-
|
|
84
|
-
.pui-input,
|
|
85
|
-
.pui-textarea {
|
|
86
|
-
width: 100%;
|
|
87
|
-
padding: 0.65em 0.85em;
|
|
88
|
-
font: inherit;
|
|
89
|
-
background: var(--bg-clr-surface);
|
|
90
|
-
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
91
|
-
border-radius: var(--radius-sm);
|
|
92
|
-
color: var(--text-clr-base);
|
|
93
|
-
transition:
|
|
94
|
-
border-color 0.12s ease,
|
|
95
|
-
box-shadow 0.12s ease;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
.pui-textarea {
|
|
99
|
-
min-height: 6rem;
|
|
100
|
-
resize: vertical;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.pui-input:focus-visible,
|
|
104
|
-
.pui-textarea:focus-visible {
|
|
105
|
-
outline: none;
|
|
106
|
-
border-color: var(--border-clr-brand);
|
|
107
|
-
box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.pui-input:disabled,
|
|
111
|
-
.pui-textarea:disabled {
|
|
112
|
-
background: var(--bg-clr-surface-2);
|
|
113
|
-
color: var(--text-clr-muted);
|
|
114
|
-
cursor: not-allowed;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/* Status states on parent .pui-field */
|
|
118
|
-
|
|
119
|
-
.pui-field[data-status='error'] .pui-input,
|
|
120
|
-
.pui-field[data-status='error'] .pui-textarea {
|
|
121
|
-
border-color: var(--border-clr-danger);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.pui-field[data-status='success'] .pui-input,
|
|
125
|
-
.pui-field[data-status='success'] .pui-textarea {
|
|
126
|
-
border-color: var(--border-clr-success);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/* ===== pui-inputgroup (prefix / suffix addons) ===== */
|
|
2
|
+
/* The `.pui-field` scaffold and the `.pui-input` surface live in
|
|
3
|
+
* `styles/4-components/form-field.css`; only the input group is TextInput's. */
|
|
130
4
|
|
|
131
5
|
.pui-inputgroup {
|
|
132
6
|
display: flex;
|
|
133
|
-
border: var(--stroke-sm) solid var(--border-clr-
|
|
7
|
+
border: var(--stroke-sm) solid var(--border-clr-input);
|
|
134
8
|
border-radius: var(--radius-sm);
|
|
135
9
|
overflow: hidden;
|
|
136
10
|
}
|
|
137
11
|
|
|
138
12
|
.pui-inputgroup:focus-within {
|
|
139
13
|
border-color: var(--border-clr-brand);
|
|
140
|
-
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* `overflow: hidden` would clip the input's own ring, so the group wears it. */
|
|
17
|
+
.pui-inputgroup:has(.pui-input:focus-visible) {
|
|
18
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
19
|
+
outline-offset: var(--outline-offset);
|
|
141
20
|
}
|
|
142
21
|
|
|
143
22
|
.pui-inputgroup .pui-input {
|
|
@@ -146,7 +25,7 @@
|
|
|
146
25
|
}
|
|
147
26
|
|
|
148
27
|
.pui-inputgroup .pui-input:focus-visible {
|
|
149
|
-
|
|
28
|
+
outline: none;
|
|
150
29
|
}
|
|
151
30
|
|
|
152
31
|
.pui-inputgroup__addon {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label class="pui-field__label" :for="inputId"
|
|
3
|
+
<label class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
4
12
|
<textarea
|
|
5
13
|
v-bind="$attrs"
|
|
6
14
|
:id="inputId"
|
|
@@ -28,7 +36,7 @@
|
|
|
28
36
|
v-if="errors.length"
|
|
29
37
|
:id="errorsId"
|
|
30
38
|
class="pui-field__hint"
|
|
31
|
-
role="
|
|
39
|
+
role="status"
|
|
32
40
|
>
|
|
33
41
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
34
42
|
</ul>
|
|
@@ -1,25 +1,9 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
/*
|
|
3
|
-
* disabled, status-driven recolour) live alongside `.pui-input` in
|
|
4
|
-
* `components/form/text-input/text-input.css` because both controls share
|
|
5
|
-
* the form-primitive scaffold (per CONVENTIONS § Component archetypes
|
|
6
|
-
* → Form-primitive wrapper). When a fifth+ primitive ships, those
|
|
7
|
-
* shared rules will move into a dedicated `form-field.css` per the
|
|
8
|
-
* deferred-extraction note in CONVENTIONS.
|
|
9
|
-
*
|
|
10
|
-
* This file only carries Textarea-specific rules that don't fit the
|
|
11
|
-
* shared scaffold — currently just the `:autoResize` opt-in styling.
|
|
12
|
-
*/
|
|
2
|
+
/* `.pui-textarea` itself is styled by `styles/4-components/form-field.css`. */
|
|
13
3
|
|
|
14
|
-
/*
|
|
15
|
-
|
|
16
|
-
* inline style is the only documented path, so the
|
|
17
|
-
* selector matches it directly. Browsers without `field-sizing`
|
|
18
|
-
* support fall back gracefully to the default fixed-rows/min-height
|
|
19
|
-
* box. */
|
|
4
|
+
/* `autoResize` sets `style="field-sizing: content"` inline; the box grows
|
|
5
|
+
* with content, so the manual resize handle is redundant. */
|
|
20
6
|
.pui-textarea[style*='field-sizing'] {
|
|
21
|
-
/* Vertical resize handle is redundant when content sizing is on;
|
|
22
|
-
* the box grows with content on its own. */
|
|
23
7
|
resize: none;
|
|
24
8
|
}
|
|
25
9
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useId, watch } from 'vue';
|
|
3
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
4
|
+
import Sidebar from '../../navigation/sidebar/Sidebar.vue';
|
|
5
|
+
import type { SidebarItem } from '../../navigation/sidebar/types.js';
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
topItems: SidebarItem[];
|
|
10
|
+
bottomItems?: SidebarItem[];
|
|
11
|
+
activeHref?: string | null;
|
|
12
|
+
collapsed?: boolean;
|
|
13
|
+
collapsedSubmenu?: 'hidden' | 'panel';
|
|
14
|
+
navigationLabel?: string;
|
|
15
|
+
drawerLabel?: string;
|
|
16
|
+
skipLabel?: string;
|
|
17
|
+
mainId?: string;
|
|
18
|
+
navigationId?: string;
|
|
19
|
+
}>(),
|
|
20
|
+
{
|
|
21
|
+
bottomItems: () => [],
|
|
22
|
+
activeHref: null,
|
|
23
|
+
collapsed: false,
|
|
24
|
+
collapsedSubmenu: 'hidden',
|
|
25
|
+
navigationLabel: 'Main navigation',
|
|
26
|
+
drawerLabel: 'Open navigation',
|
|
27
|
+
skipLabel: 'Skip to content',
|
|
28
|
+
mainId: undefined,
|
|
29
|
+
navigationId: undefined,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{ navigate: [event: MouseEvent] }>();
|
|
34
|
+
const drawerOpen = defineModel<boolean>('drawerOpen', { default: false });
|
|
35
|
+
const expandedGroups = defineModel<string[]>('expandedGroups');
|
|
36
|
+
|
|
37
|
+
const instanceId = useId();
|
|
38
|
+
const resolvedMainId = computed(() => props.mainId ?? `${instanceId}-main`);
|
|
39
|
+
const resolvedNavigationId = computed(
|
|
40
|
+
() => props.navigationId ?? `${instanceId}-navigation`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function navigate(event: MouseEvent): void {
|
|
44
|
+
emit('navigate', event);
|
|
45
|
+
const target = event.target as Element | null;
|
|
46
|
+
if (target?.closest('a[data-sidebar-link]')) drawerOpen.value = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
watch(
|
|
50
|
+
() => props.activeHref,
|
|
51
|
+
(next, previous) => {
|
|
52
|
+
if (next !== previous) drawerOpen.value = false;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<div class="pui-app-layout">
|
|
59
|
+
<a class="pui-app-layout__skip-link" :href="`#${resolvedMainId}`">
|
|
60
|
+
{{ skipLabel }}
|
|
61
|
+
</a>
|
|
62
|
+
<header class="pui-app-layout__header">
|
|
63
|
+
<IconButton
|
|
64
|
+
class="pui-app-layout__drawer-toggle"
|
|
65
|
+
:label="drawerLabel"
|
|
66
|
+
name="menu"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
:aria-expanded="drawerOpen ? 'true' : undefined"
|
|
70
|
+
:aria-controls="resolvedNavigationId"
|
|
71
|
+
@click="drawerOpen = true"
|
|
72
|
+
/>
|
|
73
|
+
<div v-if="$slots.brand" class="pui-app-layout__brand">
|
|
74
|
+
<slot name="brand" />
|
|
75
|
+
</div>
|
|
76
|
+
<div v-if="$slots.tools" class="pui-app-layout__tools">
|
|
77
|
+
<slot name="tools" />
|
|
78
|
+
</div>
|
|
79
|
+
</header>
|
|
80
|
+
<div class="pui-app-layout__body">
|
|
81
|
+
<Sidebar
|
|
82
|
+
:id="resolvedNavigationId"
|
|
83
|
+
class="pui-app-layout__navigation"
|
|
84
|
+
:top-items="topItems"
|
|
85
|
+
:bottom-items="bottomItems"
|
|
86
|
+
:active-href="activeHref"
|
|
87
|
+
:collapsed="collapsed"
|
|
88
|
+
:collapsed-submenu="collapsedSubmenu"
|
|
89
|
+
:drawer-open="drawerOpen"
|
|
90
|
+
:expanded-groups="expandedGroups"
|
|
91
|
+
:aria-label="navigationLabel"
|
|
92
|
+
@click="navigate"
|
|
93
|
+
@update:drawer-open="drawerOpen = $event"
|
|
94
|
+
@update:expanded-groups="expandedGroups = $event"
|
|
95
|
+
>
|
|
96
|
+
<template v-if="$slots['navigation-brand']" #brand>
|
|
97
|
+
<slot name="navigation-brand" />
|
|
98
|
+
</template>
|
|
99
|
+
<template v-if="$slots['navigation-bottom']" #bottom>
|
|
100
|
+
<slot name="navigation-bottom" />
|
|
101
|
+
</template>
|
|
102
|
+
</Sidebar>
|
|
103
|
+
<main
|
|
104
|
+
:id="resolvedMainId"
|
|
105
|
+
class="pui-app-layout__main"
|
|
106
|
+
tabindex="-1"
|
|
107
|
+
>
|
|
108
|
+
<slot />
|
|
109
|
+
</main>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
@import './app-layout.css';
|
|
116
|
+
</style>
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="pui-index"
|
|
4
|
+
:data-placement="placement"
|
|
5
|
+
:data-state="loading ? 'loading' : undefined"
|
|
6
|
+
>
|
|
3
7
|
<header
|
|
4
8
|
v-if="title || description || $slots.actions"
|
|
5
9
|
class="pui-index__header"
|
|
6
10
|
>
|
|
7
11
|
<div v-if="title || description" class="pui-index__heading">
|
|
8
|
-
<
|
|
12
|
+
<component
|
|
13
|
+
:is="placement === 'related' ? 'h2' : 'h1'"
|
|
14
|
+
v-if="title"
|
|
15
|
+
:id="titleId"
|
|
16
|
+
class="pui-index__title"
|
|
17
|
+
>{{ title }}</component
|
|
18
|
+
>
|
|
9
19
|
<p v-if="description" class="pui-index__description">
|
|
10
20
|
{{ description }}
|
|
11
21
|
</p>
|
|
@@ -51,11 +61,9 @@
|
|
|
51
61
|
</div>
|
|
52
62
|
</div>
|
|
53
63
|
|
|
54
|
-
<div
|
|
55
|
-
v-if="selectable && selected.length && $slots.selection"
|
|
56
|
-
class="pui-index__selection"
|
|
57
|
-
>
|
|
64
|
+
<div class="pui-index__selection" role="status">
|
|
58
65
|
<slot
|
|
66
|
+
v-if="selectable && selected.length && $slots.selection"
|
|
59
67
|
name="selection"
|
|
60
68
|
:selected="selected"
|
|
61
69
|
:reload="reload"
|
|
@@ -107,6 +115,8 @@
|
|
|
107
115
|
:selectable="selectable"
|
|
108
116
|
:selected="selected"
|
|
109
117
|
:clickable="clickable"
|
|
118
|
+
:label="title ? undefined : tableLabel"
|
|
119
|
+
:labelledby="title ? titleId : undefined"
|
|
110
120
|
@sort="
|
|
111
121
|
updateCriteria({ sort: toggleSort(query.sort, $event) })
|
|
112
122
|
"
|
|
@@ -129,9 +139,11 @@
|
|
|
129
139
|
|
|
130
140
|
<PaginationFooter v-if="showResults" class="pui-index__footer">
|
|
131
141
|
<template #summary>
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
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>
|
|
135
147
|
</template>
|
|
136
148
|
<div class="pui-index__pagination">
|
|
137
149
|
<div v-if="pageSizes.length" class="pui-index__page-size">
|
|
@@ -173,6 +185,7 @@ import type {
|
|
|
173
185
|
QueryOptions,
|
|
174
186
|
} from '../../../utils/cms/index.js';
|
|
175
187
|
import Button from '../../action/button/Button.vue';
|
|
188
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
176
189
|
import { useIndex } from './useIndex.js';
|
|
177
190
|
import {
|
|
178
191
|
formatSort,
|
|
@@ -186,12 +199,16 @@ const props = withDefaults(
|
|
|
186
199
|
load: ListLoader<T>;
|
|
187
200
|
columns: Column[];
|
|
188
201
|
queryOptions: QueryOptions;
|
|
189
|
-
/**
|
|
190
|
-
|
|
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;
|
|
191
206
|
/** Delay in milliseconds before committing typed search text. */
|
|
192
207
|
searchDebounce?: number;
|
|
193
208
|
title?: string;
|
|
194
209
|
description?: string;
|
|
210
|
+
/** Accessible name for the table when there is no `title` to reference. */
|
|
211
|
+
tableLabel?: string;
|
|
195
212
|
searchable?: boolean;
|
|
196
213
|
searchLabel?: string;
|
|
197
214
|
searchPlaceholder?: string;
|
|
@@ -207,10 +224,12 @@ const props = withDefaults(
|
|
|
207
224
|
clickable?: boolean;
|
|
208
225
|
}>(),
|
|
209
226
|
{
|
|
227
|
+
placement: 'module',
|
|
210
228
|
syncQuery: false,
|
|
211
229
|
searchDebounce: 250,
|
|
212
230
|
title: undefined,
|
|
213
231
|
description: undefined,
|
|
232
|
+
tableLabel: undefined,
|
|
214
233
|
searchable: true,
|
|
215
234
|
searchLabel: 'Search',
|
|
216
235
|
searchPlaceholder: undefined,
|
|
@@ -291,6 +310,7 @@ const {
|
|
|
291
310
|
reset,
|
|
292
311
|
} = useIndex<T>(props);
|
|
293
312
|
defineExpose({ reload });
|
|
313
|
+
const titleId = generateId('index-title');
|
|
294
314
|
const total = computed(() => meta.value.total);
|
|
295
315
|
const showResults = computed(
|
|
296
316
|
() =>
|
|
@@ -46,14 +46,6 @@
|
|
|
46
46
|
|
|
47
47
|
.pui-index__body {
|
|
48
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
49
|
}
|
|
58
50
|
|
|
59
51
|
.pui-index__toolbar {
|
|
@@ -61,8 +53,6 @@
|
|
|
61
53
|
flex-wrap: wrap;
|
|
62
54
|
align-items: end;
|
|
63
55
|
gap: var(--space-s);
|
|
64
|
-
padding: var(--space-s);
|
|
65
|
-
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
.pui-index__search {
|
|
@@ -86,8 +76,6 @@
|
|
|
86
76
|
|
|
87
77
|
.pui-index__table {
|
|
88
78
|
--pui-table-cell-padding-block: var(--space-2xs);
|
|
89
|
-
border: 0;
|
|
90
|
-
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
91
79
|
}
|
|
92
80
|
|
|
93
81
|
.pui-index__page-size > .pui-field {
|
|
@@ -111,4 +99,59 @@
|
|
|
111
99
|
flex: 1;
|
|
112
100
|
}
|
|
113
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
|
+
}
|
|
114
157
|
}
|