@pienter/ui 0.5.0 → 0.8.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 +149 -0
- package/CONVENTIONS.md +91 -28
- package/README.md +66 -0
- 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.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 +455 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +32 -39
- 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 -3
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- 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/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 -129
- 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 +373 -0
- package/components/layout/index/index.css +157 -0
- package/components/layout/index/useIndex.ts +407 -0
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +20 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useMenu.ts +20 -27
- package/package.json +17 -2
- package/styles/0-settings/colors.css +10 -0
- 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/focus.ts +9 -3
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { computed, inject, onScopeDispose, ref, shallowRef, watch } from 'vue';
|
|
2
|
+
import {
|
|
3
|
+
routeLocationKey,
|
|
4
|
+
routerKey,
|
|
5
|
+
isNavigationFailure,
|
|
6
|
+
NavigationFailureType,
|
|
7
|
+
type LocationQuery,
|
|
8
|
+
} from 'vue-router';
|
|
9
|
+
import {
|
|
10
|
+
cloneRecord,
|
|
11
|
+
decodeListQuery,
|
|
12
|
+
encodeListQuery,
|
|
13
|
+
normalizeListQuery,
|
|
14
|
+
type ApiError,
|
|
15
|
+
type ListLoader,
|
|
16
|
+
type ListQuery,
|
|
17
|
+
type QueryOptions,
|
|
18
|
+
} from '../../../utils/cms/index.js';
|
|
19
|
+
|
|
20
|
+
interface Options<T> {
|
|
21
|
+
load: ListLoader<T>;
|
|
22
|
+
queryOptions: QueryOptions;
|
|
23
|
+
/** `true` syncs the bare keys; a string syncs `<prefix>.<key>` instead. */
|
|
24
|
+
syncQuery: boolean | string;
|
|
25
|
+
searchDebounce: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isListParameter(key: string): boolean {
|
|
29
|
+
return (
|
|
30
|
+
key === 'filter' ||
|
|
31
|
+
key.startsWith('filter[') ||
|
|
32
|
+
['page', 'page_size', 'search', 'sort'].some(
|
|
33
|
+
(name) => key === name || key.startsWith(`${name}[`),
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
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 {
|
|
45
|
+
const params = new URLSearchParams();
|
|
46
|
+
for (const [key, valuesForKey] of Object.entries(values)) {
|
|
47
|
+
const bare = listKey(key, prefix);
|
|
48
|
+
if (bare === undefined) continue;
|
|
49
|
+
for (const value of Array.isArray(valuesForKey)
|
|
50
|
+
? valuesForKey
|
|
51
|
+
: [valuesForKey])
|
|
52
|
+
params.append(bare, value ?? '');
|
|
53
|
+
}
|
|
54
|
+
params.sort();
|
|
55
|
+
return params;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function apiError(cause: unknown): ApiError {
|
|
59
|
+
const candidate =
|
|
60
|
+
cause && typeof cause === 'object' && 'error' in cause
|
|
61
|
+
? cause.error
|
|
62
|
+
: cause;
|
|
63
|
+
if (
|
|
64
|
+
candidate &&
|
|
65
|
+
typeof candidate === 'object' &&
|
|
66
|
+
'code' in candidate &&
|
|
67
|
+
typeof candidate.code === 'string' &&
|
|
68
|
+
'message' in candidate &&
|
|
69
|
+
typeof candidate.message === 'string'
|
|
70
|
+
)
|
|
71
|
+
return candidate as ApiError;
|
|
72
|
+
return {
|
|
73
|
+
code: 'load_failed',
|
|
74
|
+
message:
|
|
75
|
+
cause instanceof Error
|
|
76
|
+
? cause.message
|
|
77
|
+
: 'The list could not be loaded.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Work {
|
|
82
|
+
promise: Promise<void>;
|
|
83
|
+
finish: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useIndex<T>(props: Options<T>) {
|
|
87
|
+
const route = inject(routeLocationKey, undefined);
|
|
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
|
+
: '';
|
|
94
|
+
const defaults = () =>
|
|
95
|
+
normalizeListQuery(
|
|
96
|
+
{
|
|
97
|
+
page: 1,
|
|
98
|
+
page_size: props.queryOptions.defaultPageSize ?? 20,
|
|
99
|
+
search: '',
|
|
100
|
+
sort: null,
|
|
101
|
+
filters: {},
|
|
102
|
+
},
|
|
103
|
+
props.queryOptions,
|
|
104
|
+
);
|
|
105
|
+
const query = shallowRef<ListQuery>(defaults());
|
|
106
|
+
const search = ref(query.value.search);
|
|
107
|
+
const rows = shallowRef<T[]>([]);
|
|
108
|
+
const meta = shallowRef({
|
|
109
|
+
page: query.value.page,
|
|
110
|
+
page_size: query.value.page_size,
|
|
111
|
+
total: 0,
|
|
112
|
+
});
|
|
113
|
+
const loading = ref(false);
|
|
114
|
+
const error = shallowRef<ApiError | null>(null);
|
|
115
|
+
const errors = computed(() => (error.value ? [error.value.message] : []));
|
|
116
|
+
const invalidQuery = ref(false);
|
|
117
|
+
let request = 0;
|
|
118
|
+
let navigation = 0;
|
|
119
|
+
let controller: AbortController | undefined;
|
|
120
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
121
|
+
let disposed = false;
|
|
122
|
+
let activeWork: Work | undefined;
|
|
123
|
+
let pendingUrl: { path: string; query: string; work: Work } | undefined;
|
|
124
|
+
|
|
125
|
+
function startWork(): Work {
|
|
126
|
+
activeWork?.finish();
|
|
127
|
+
cancelRequest();
|
|
128
|
+
let finish!: () => void;
|
|
129
|
+
const promise = new Promise<void>((resolve) => {
|
|
130
|
+
finish = resolve;
|
|
131
|
+
});
|
|
132
|
+
activeWork = { promise, finish };
|
|
133
|
+
return activeWork;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fail(cause: unknown) {
|
|
137
|
+
error.value = apiError(cause);
|
|
138
|
+
invalidQuery.value = error.value.code === 'invalid_query';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cancelSearch() {
|
|
142
|
+
clearTimeout(searchTimer);
|
|
143
|
+
searchTimer = undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cancelRequest() {
|
|
147
|
+
request++;
|
|
148
|
+
controller?.abort();
|
|
149
|
+
loading.value = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fetchRows(work = startWork()): Promise<void> {
|
|
153
|
+
cancelRequest();
|
|
154
|
+
if (disposed || invalidQuery.value) {
|
|
155
|
+
work.finish();
|
|
156
|
+
return work.promise;
|
|
157
|
+
}
|
|
158
|
+
const current = request;
|
|
159
|
+
controller = new AbortController();
|
|
160
|
+
const { signal } = controller;
|
|
161
|
+
loading.value = true;
|
|
162
|
+
error.value = null;
|
|
163
|
+
void (async () => {
|
|
164
|
+
try {
|
|
165
|
+
const response = await props.load(cloneRecord(query.value), {
|
|
166
|
+
signal,
|
|
167
|
+
});
|
|
168
|
+
if (current !== request) return;
|
|
169
|
+
rows.value = response.data;
|
|
170
|
+
meta.value = response.meta;
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
if (current === request) fail(cause);
|
|
173
|
+
} finally {
|
|
174
|
+
if (current === request) loading.value = false;
|
|
175
|
+
work.finish();
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
return work.promise;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function accept(next: ListQuery, work: Work) {
|
|
182
|
+
query.value = next;
|
|
183
|
+
search.value = next.search;
|
|
184
|
+
invalidQuery.value = false;
|
|
185
|
+
return fetchRows(work);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const urlQuery = computed(() =>
|
|
189
|
+
syncing() && route ? routeParams(route.query, prefix()).toString() : '',
|
|
190
|
+
);
|
|
191
|
+
watch(
|
|
192
|
+
[
|
|
193
|
+
urlQuery,
|
|
194
|
+
() => (syncing() ? route?.path : ''),
|
|
195
|
+
() => props.syncQuery,
|
|
196
|
+
() => props.queryOptions,
|
|
197
|
+
() => props.load,
|
|
198
|
+
],
|
|
199
|
+
() => {
|
|
200
|
+
const matchesPending =
|
|
201
|
+
pendingUrl?.path === route?.path &&
|
|
202
|
+
pendingUrl?.query === urlQuery.value;
|
|
203
|
+
const preserveSearch = searchTimer !== undefined && matchesPending;
|
|
204
|
+
const work = preserveSearch
|
|
205
|
+
? activeWork!
|
|
206
|
+
: matchesPending
|
|
207
|
+
? pendingUrl!.work
|
|
208
|
+
: startWork();
|
|
209
|
+
pendingUrl = undefined;
|
|
210
|
+
navigation++;
|
|
211
|
+
if (!preserveSearch) cancelSearch();
|
|
212
|
+
cancelRequest();
|
|
213
|
+
if (syncing() && (!route || !router))
|
|
214
|
+
throw new Error(
|
|
215
|
+
'Index sync-query requires an installed Vue Router.',
|
|
216
|
+
);
|
|
217
|
+
try {
|
|
218
|
+
const next = syncing()
|
|
219
|
+
? decodeListQuery(
|
|
220
|
+
new URLSearchParams(urlQuery.value),
|
|
221
|
+
props.queryOptions,
|
|
222
|
+
)
|
|
223
|
+
: normalizeListQuery(query.value, props.queryOptions);
|
|
224
|
+
if (preserveSearch) {
|
|
225
|
+
query.value = next;
|
|
226
|
+
invalidQuery.value = false;
|
|
227
|
+
loading.value = true;
|
|
228
|
+
} else void accept(next, work);
|
|
229
|
+
} catch (cause) {
|
|
230
|
+
fail(cause);
|
|
231
|
+
work.finish();
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
{ immediate: true, deep: true },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
function commit(next: ListQuery, replace = false): Promise<void> {
|
|
238
|
+
if (disposed) return Promise.resolve();
|
|
239
|
+
cancelSearch();
|
|
240
|
+
const work = startWork();
|
|
241
|
+
let normalized: ListQuery;
|
|
242
|
+
try {
|
|
243
|
+
normalized = normalizeListQuery(next, props.queryOptions);
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
fail(cause);
|
|
246
|
+
work.finish();
|
|
247
|
+
return work.promise;
|
|
248
|
+
}
|
|
249
|
+
if (!syncing() || !route || !router) return accept(normalized, work);
|
|
250
|
+
const keyPrefix = prefix();
|
|
251
|
+
const values = { ...route.query };
|
|
252
|
+
for (const key of Object.keys(values))
|
|
253
|
+
if (listKey(key, keyPrefix) !== undefined) delete values[key];
|
|
254
|
+
for (const [key, value] of encodeListQuery(normalized))
|
|
255
|
+
values[keyPrefix + key] = value;
|
|
256
|
+
query.value = normalized;
|
|
257
|
+
search.value = normalized.search;
|
|
258
|
+
const attempt = ++navigation;
|
|
259
|
+
const path = route.path;
|
|
260
|
+
pendingUrl = {
|
|
261
|
+
path,
|
|
262
|
+
query: routeParams(values, keyPrefix).toString(),
|
|
263
|
+
work,
|
|
264
|
+
};
|
|
265
|
+
loading.value = true;
|
|
266
|
+
void (async () => {
|
|
267
|
+
try {
|
|
268
|
+
const failure = await router[replace ? 'replace' : 'push']({
|
|
269
|
+
query: values,
|
|
270
|
+
hash: route.hash,
|
|
271
|
+
});
|
|
272
|
+
if (disposed || attempt !== navigation || route.path !== path)
|
|
273
|
+
return;
|
|
274
|
+
if (
|
|
275
|
+
isNavigationFailure(
|
|
276
|
+
failure,
|
|
277
|
+
NavigationFailureType.duplicated,
|
|
278
|
+
)
|
|
279
|
+
) {
|
|
280
|
+
pendingUrl = undefined;
|
|
281
|
+
if (searchTimer !== undefined) {
|
|
282
|
+
query.value = normalized;
|
|
283
|
+
invalidQuery.value = false;
|
|
284
|
+
} else void accept(normalized, work);
|
|
285
|
+
} else if (failure)
|
|
286
|
+
failNavigation(
|
|
287
|
+
new Error('The list URL could not be updated.'),
|
|
288
|
+
work,
|
|
289
|
+
);
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
if (disposed || attempt !== navigation || route.path !== path)
|
|
292
|
+
return;
|
|
293
|
+
failNavigation(cause, work);
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
return work.promise;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function failNavigation(cause: unknown, work: Work) {
|
|
300
|
+
pendingUrl = undefined;
|
|
301
|
+
const preserveSearch = searchTimer !== undefined;
|
|
302
|
+
cancelRequest();
|
|
303
|
+
try {
|
|
304
|
+
query.value = decodeListQuery(
|
|
305
|
+
routeParams(route!.query, prefix()),
|
|
306
|
+
props.queryOptions,
|
|
307
|
+
);
|
|
308
|
+
if (!preserveSearch) search.value = query.value.search;
|
|
309
|
+
invalidQuery.value = false;
|
|
310
|
+
} catch {
|
|
311
|
+
invalidQuery.value = true;
|
|
312
|
+
}
|
|
313
|
+
error.value = apiError(cause);
|
|
314
|
+
loading.value = preserveSearch;
|
|
315
|
+
work.finish();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function updateCriteria(patch: Partial<Omit<ListQuery, 'page'>>) {
|
|
319
|
+
void commit({
|
|
320
|
+
...query.value,
|
|
321
|
+
search: search.value,
|
|
322
|
+
...patch,
|
|
323
|
+
page: 1,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function setSearch(value: string) {
|
|
328
|
+
if (disposed) return;
|
|
329
|
+
cancelSearch();
|
|
330
|
+
startWork();
|
|
331
|
+
search.value = value;
|
|
332
|
+
loading.value = true;
|
|
333
|
+
searchTimer = setTimeout(
|
|
334
|
+
() => {
|
|
335
|
+
void commit({ ...query.value, search: value, page: 1 }, true);
|
|
336
|
+
},
|
|
337
|
+
Math.max(0, props.searchDebounce),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function reload(): Promise<void> {
|
|
342
|
+
cancelSearch();
|
|
343
|
+
if (pendingUrl || search.value !== query.value.search)
|
|
344
|
+
return commit(
|
|
345
|
+
{
|
|
346
|
+
...query.value,
|
|
347
|
+
search: search.value,
|
|
348
|
+
page:
|
|
349
|
+
search.value !== query.value.search
|
|
350
|
+
? 1
|
|
351
|
+
: query.value.page,
|
|
352
|
+
},
|
|
353
|
+
true,
|
|
354
|
+
);
|
|
355
|
+
return fetchRows();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function reset(): Promise<void> {
|
|
359
|
+
return commit(defaults(), true);
|
|
360
|
+
}
|
|
361
|
+
function setPage(page: number) {
|
|
362
|
+
void commit({
|
|
363
|
+
...query.value,
|
|
364
|
+
search: search.value,
|
|
365
|
+
page: search.value !== query.value.search ? 1 : page,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function setFilters(filters: ListQuery['filters']) {
|
|
369
|
+
updateCriteria({ filters: { ...filters } });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function setFilter(name: string, value: string | undefined) {
|
|
373
|
+
if (value !== undefined) {
|
|
374
|
+
setFilters({ ...query.value.filters, [name]: value });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const filters = { ...query.value.filters };
|
|
378
|
+
delete filters[name];
|
|
379
|
+
setFilters(filters);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
onScopeDispose(() => {
|
|
383
|
+
disposed = true;
|
|
384
|
+
navigation++;
|
|
385
|
+
cancelSearch();
|
|
386
|
+
cancelRequest();
|
|
387
|
+
activeWork?.finish();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
query,
|
|
392
|
+
search,
|
|
393
|
+
rows,
|
|
394
|
+
meta,
|
|
395
|
+
loading,
|
|
396
|
+
error,
|
|
397
|
+
errors,
|
|
398
|
+
invalidQuery,
|
|
399
|
+
updateCriteria,
|
|
400
|
+
setSearch,
|
|
401
|
+
setPage,
|
|
402
|
+
setFilters,
|
|
403
|
+
setFilter,
|
|
404
|
+
reload,
|
|
405
|
+
reset,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
@@ -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
|
|
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
align-items: center;
|
|
67
67
|
gap: var(--space-2xs);
|
|
68
68
|
min-width: 0;
|
|
69
|
-
padding: var(--
|
|
69
|
+
padding-block: var(--pui-table-cell-padding-block, var(--space-xs));
|
|
70
|
+
padding-inline: var(--space-s);
|
|
70
71
|
text-align: left;
|
|
71
72
|
|
|
72
73
|
&[data-head] {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<nav aria-label="Breadcrumb">
|
|
3
|
-
<ol class="pui-
|
|
2
|
+
<nav aria-label="Breadcrumb" class="pui-breadcrumb">
|
|
3
|
+
<ol class="pui-breadcrumb__trail">
|
|
4
4
|
<template v-for="(item, index) in items" :key="index">
|
|
5
5
|
<li
|
|
6
6
|
v-if="index > 0"
|
|
@@ -13,15 +13,25 @@
|
|
|
13
13
|
v-if="index < items.length - 1"
|
|
14
14
|
class="pui-breadcrumb__item"
|
|
15
15
|
>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
<slot
|
|
17
|
+
name="link"
|
|
18
|
+
:item="item"
|
|
19
|
+
:index="index"
|
|
20
|
+
link-class="pui-breadcrumb__link"
|
|
21
|
+
>
|
|
22
|
+
<a :href="item.href" class="pui-breadcrumb__link">{{
|
|
23
|
+
item.label
|
|
24
|
+
}}</a>
|
|
25
|
+
</slot>
|
|
19
26
|
</li>
|
|
20
27
|
<li v-else class="pui-breadcrumb__item" aria-current="page">
|
|
21
28
|
{{ item.label }}
|
|
22
29
|
</li>
|
|
23
30
|
</template>
|
|
24
31
|
</ol>
|
|
32
|
+
<div v-if="$slots.actions" class="pui-breadcrumb__actions">
|
|
33
|
+
<slot name="actions" />
|
|
34
|
+
</div>
|
|
25
35
|
</nav>
|
|
26
36
|
</template>
|
|
27
37
|
|
|
@@ -29,6 +39,15 @@
|
|
|
29
39
|
defineProps<{
|
|
30
40
|
items: { label: string; href?: string }[];
|
|
31
41
|
}>();
|
|
42
|
+
|
|
43
|
+
defineSlots<{
|
|
44
|
+
link?: (scope: {
|
|
45
|
+
item: { label: string; href?: string };
|
|
46
|
+
index: number;
|
|
47
|
+
linkClass: string;
|
|
48
|
+
}) => unknown;
|
|
49
|
+
actions?: () => unknown;
|
|
50
|
+
}>();
|
|
32
51
|
</script>
|
|
33
52
|
|
|
34
53
|
<style>
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
@layer components {
|
|
2
2
|
.pui-breadcrumb {
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: space-between;
|
|
6
|
+
gap: var(--space-s);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.pui-breadcrumb__trail {
|
|
3
10
|
display: flex;
|
|
4
11
|
align-items: center;
|
|
5
12
|
gap: var(--space-2xs);
|
|
@@ -9,6 +16,14 @@
|
|
|
9
16
|
padding: 0;
|
|
10
17
|
margin: 0;
|
|
11
18
|
flex-wrap: wrap;
|
|
19
|
+
min-inline-size: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.pui-breadcrumb__actions {
|
|
23
|
+
display: inline-flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: var(--space-2xs);
|
|
26
|
+
flex: none;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
29
|
.pui-breadcrumb__item {
|
|
@@ -26,6 +41,11 @@
|
|
|
26
41
|
color: var(--text-clr-base);
|
|
27
42
|
}
|
|
28
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
|
+
|
|
29
49
|
.pui-breadcrumb__sep {
|
|
30
50
|
color: var(--border-clr-base);
|
|
31
51
|
}
|
|
@@ -323,6 +323,17 @@ function onEntryKeydown(event: KeyboardEvent): void {
|
|
|
323
323
|
const item = event.currentTarget;
|
|
324
324
|
if (!(item instanceof HTMLElement)) return;
|
|
325
325
|
|
|
326
|
+
if (event.key === ' ' || event.key === 'Spacebar') {
|
|
327
|
+
// `<a>` doesn't activate on Space natively.
|
|
328
|
+
if (item.matches('[data-sidebar-link]')) {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
item.click();
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
|
336
|
+
|
|
326
337
|
const entries = focusableEntries();
|
|
327
338
|
const idx = entries.indexOf(item);
|
|
328
339
|
|
|
@@ -343,14 +354,6 @@ function onEntryKeydown(event: KeyboardEvent): void {
|
|
|
343
354
|
event.preventDefault();
|
|
344
355
|
entries[entries.length - 1]?.focus();
|
|
345
356
|
return;
|
|
346
|
-
case ' ':
|
|
347
|
-
case 'Spacebar':
|
|
348
|
-
// `<a>` doesn't activate on Space natively.
|
|
349
|
-
if (item.matches('[data-sidebar-link]')) {
|
|
350
|
-
event.preventDefault();
|
|
351
|
-
item.click();
|
|
352
|
-
}
|
|
353
|
-
return;
|
|
354
357
|
default:
|
|
355
358
|
return;
|
|
356
359
|
}
|
|
@@ -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
|
}
|