@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
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
<div class="pui-tabs" role="tablist">
|
|
3
3
|
<button
|
|
4
4
|
v-for="tab in tabs"
|
|
5
|
+
:id="tab.id"
|
|
5
6
|
:key="tab.key"
|
|
6
7
|
type="button"
|
|
7
8
|
class="pui-tabs__item"
|
|
8
9
|
role="tab"
|
|
10
|
+
:aria-controls="tab.controls"
|
|
9
11
|
:aria-selected="modelValue === tab.key ? 'true' : 'false'"
|
|
10
12
|
:tabindex="modelValue === tab.key ? 0 : -1"
|
|
11
13
|
:disabled="tab.disabled || undefined"
|
|
@@ -22,6 +24,8 @@ interface TabItem {
|
|
|
22
24
|
key: string;
|
|
23
25
|
label: string;
|
|
24
26
|
disabled?: boolean;
|
|
27
|
+
id?: string;
|
|
28
|
+
controls?: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const props = withDefaults(
|
|
@@ -39,6 +43,8 @@ const emit = defineEmits<{
|
|
|
39
43
|
}>();
|
|
40
44
|
|
|
41
45
|
function handleKeydown(event: KeyboardEvent): void {
|
|
46
|
+
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return;
|
|
47
|
+
|
|
42
48
|
const target = event.currentTarget as HTMLElement;
|
|
43
49
|
const items = Array.from(
|
|
44
50
|
target.parentElement!.querySelectorAll<HTMLElement>(
|
|
@@ -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 {
|
package/composables/useMenu.ts
CHANGED
|
@@ -85,31 +85,30 @@ export function useMenu(
|
|
|
85
85
|
return getItems().filter((it) => !isDisabled(it));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
function setRovingTabindex(
|
|
89
|
+
activeItem: HTMLElement | undefined,
|
|
90
|
+
items = getItems(),
|
|
91
|
+
): void {
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
item.setAttribute('tabindex', item === activeItem ? '0' : '-1');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
91
97
|
function initRovingTabindex(): void {
|
|
92
98
|
const items = getItems();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
const firstEnabledIdx = items.findIndex((i) => !isDisabled(i));
|
|
99
|
-
item.setAttribute('tabindex', idx === firstEnabledIdx ? '0' : '-1');
|
|
100
|
-
});
|
|
99
|
+
setRovingTabindex(
|
|
100
|
+
items.find((item) => !isDisabled(item)),
|
|
101
|
+
items,
|
|
102
|
+
);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function focusItem(item: HTMLElement): void {
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
111
|
-
});
|
|
112
|
-
item.focus();
|
|
106
|
+
// Focusing the current item does not fire focusin.
|
|
107
|
+
if (document.activeElement === item) setRovingTabindex(item);
|
|
108
|
+
else {
|
|
109
|
+
item.setAttribute('tabindex', '0');
|
|
110
|
+
item.focus();
|
|
111
|
+
}
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
function moveFocus(direction: 1 | -1): void {
|
|
@@ -191,13 +190,7 @@ export function useMenu(
|
|
|
191
190
|
if (!menuEl || !target) return;
|
|
192
191
|
const item = target.closest<HTMLElement>(itemSelector);
|
|
193
192
|
if (!item || !menuEl.contains(item) || isDisabled(item)) return;
|
|
194
|
-
|
|
195
|
-
// `tabindex="0"`. This catches focus that didn't go through
|
|
196
|
-
// `focusItem` (e.g., a click that focused an item directly).
|
|
197
|
-
const items = getItems();
|
|
198
|
-
items.forEach((it) => {
|
|
199
|
-
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
200
|
-
});
|
|
193
|
+
setRovingTabindex(item);
|
|
201
194
|
}
|
|
202
195
|
|
|
203
196
|
onMounted(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pienter/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -87,7 +90,19 @@
|
|
|
87
90
|
"./icons": "./icons/index.ts",
|
|
88
91
|
"./icons/*": "./icons/*",
|
|
89
92
|
"./utils": "./utils/index.ts",
|
|
90
|
-
"./utils/*": "./utils/*"
|
|
93
|
+
"./utils/*": "./utils/*",
|
|
94
|
+
"./toast": "./components/feedback/toast/toast.ts",
|
|
95
|
+
"./utils/a11y": "./utils/a11y/index.ts",
|
|
96
|
+
"./components/Index.vue": "./components/layout/index/Index.vue",
|
|
97
|
+
"./components/RecordForm.vue": "./components/form/record-form/RecordForm.vue",
|
|
98
|
+
"./components/RecordForm.types": "./components/form/record-form/types.ts",
|
|
99
|
+
"./components/RecordDetails.vue": "./components/display/record-details/RecordDetails.vue",
|
|
100
|
+
"./components/RecordDetails.types": "./components/display/record-details/types.ts",
|
|
101
|
+
"./utils/cms": "./utils/cms/index.ts",
|
|
102
|
+
"./utils/cms/schema.json": "./utils/cms/schema.json",
|
|
103
|
+
"./components/RecordFields.vue": "./components/form/record-form/RecordFields.vue",
|
|
104
|
+
"./components/BlockEditor.vue": "./components/form/block-editor/BlockEditor.vue",
|
|
105
|
+
"./components/BlockEditor.types": "./components/form/block-editor/types.ts"
|
|
91
106
|
},
|
|
92
107
|
"files": [
|
|
93
108
|
"styles",
|
|
@@ -195,6 +195,11 @@
|
|
|
195
195
|
/* ---- Background -------------------------------------------------- */
|
|
196
196
|
--bg-clr-base: var(--clr-mantle);
|
|
197
197
|
--bg-clr-surface: var(--clr-base);
|
|
198
|
+
--bg-clr-canvas: color-mix(
|
|
199
|
+
in oklab,
|
|
200
|
+
var(--bg-clr-base),
|
|
201
|
+
var(--bg-clr-surface) 65%
|
|
202
|
+
);
|
|
198
203
|
--bg-clr-surface-2: var(--clr-surface-0);
|
|
199
204
|
--bg-clr-accent: var(--clr-surface-0);
|
|
200
205
|
/* The neutral "selected" rung — one step past the hover fill, so a chosen
|
|
@@ -236,6 +241,11 @@
|
|
|
236
241
|
|
|
237
242
|
/* ---- Border ------------------------------------------------------ */
|
|
238
243
|
--border-clr-base: var(--clr-surface-1);
|
|
244
|
+
--border-clr-subtle: color-mix(
|
|
245
|
+
in oklab,
|
|
246
|
+
var(--border-clr-base) 40%,
|
|
247
|
+
var(--bg-clr-surface)
|
|
248
|
+
);
|
|
239
249
|
--border-clr-strong: var(--clr-text);
|
|
240
250
|
--border-clr-input: var(--clr-overlay);
|
|
241
251
|
--border-clr-brand: var(--clr-blue-fg);
|
|
@@ -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/focus.ts
CHANGED
|
@@ -45,8 +45,12 @@ export function createFocusTrap(container: HTMLElement): FocusTrap {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function focusFirstInvalid(formEl: HTMLElement): boolean {
|
|
48
|
-
const firstInvalid =
|
|
49
|
-
'[aria-invalid="true"]',
|
|
48
|
+
const firstInvalid = Array.from(
|
|
49
|
+
formEl.querySelectorAll<HTMLElement>('[aria-invalid="true"]'),
|
|
50
|
+
).find(
|
|
51
|
+
(element) =>
|
|
52
|
+
!element.matches(':disabled') &&
|
|
53
|
+
element.checkVisibility({ visibilityProperty: true }),
|
|
50
54
|
);
|
|
51
55
|
if (!firstInvalid) return false;
|
|
52
56
|
|
|
@@ -56,7 +60,9 @@ export function focusFirstInvalid(formEl: HTMLElement): boolean {
|
|
|
56
60
|
// primitives (fieldsets of date pickers, checkbox groups, etc.) inherit
|
|
57
61
|
// this behaviour without re-derivation.
|
|
58
62
|
if (firstInvalid instanceof HTMLFieldSetElement) {
|
|
59
|
-
const firstFocusable = getFocusableElements(firstInvalid)
|
|
63
|
+
const firstFocusable = getFocusableElements(firstInvalid).find(
|
|
64
|
+
(element) => element.checkVisibility({ visibilityProperty: true }),
|
|
65
|
+
);
|
|
60
66
|
if (firstFocusable) {
|
|
61
67
|
firstFocusable.focus();
|
|
62
68
|
return true;
|
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');
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { formatSort, parseSort, type SortState } from '../sort/index.js';
|
|
2
|
+
|
|
3
|
+
export type JsonValue =
|
|
4
|
+
string | number | boolean | null | JsonObject | JsonValue[];
|
|
5
|
+
export interface JsonObject {
|
|
6
|
+
[key: string]: JsonValue;
|
|
7
|
+
}
|
|
8
|
+
export type ValuePath = readonly (string | number)[];
|
|
9
|
+
export interface ValidationIssue {
|
|
10
|
+
path: ValuePath;
|
|
11
|
+
messages: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface ApiError {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
issues?: ValidationIssue[];
|
|
17
|
+
}
|
|
18
|
+
export interface ErrorResponse {
|
|
19
|
+
error: ApiError;
|
|
20
|
+
}
|
|
21
|
+
export interface ItemResponse<T> {
|
|
22
|
+
data: T;
|
|
23
|
+
}
|
|
24
|
+
export interface ListResponse<T> {
|
|
25
|
+
data: T[];
|
|
26
|
+
meta: { page: number; page_size: number; total: number };
|
|
27
|
+
}
|
|
28
|
+
export type ListLoader<T> = (
|
|
29
|
+
query: ListQuery,
|
|
30
|
+
context: { signal: AbortSignal },
|
|
31
|
+
) => Promise<ListResponse<T>>;
|
|
32
|
+
export interface ListQuery {
|
|
33
|
+
page: number;
|
|
34
|
+
page_size: number;
|
|
35
|
+
search: string;
|
|
36
|
+
sort: SortState;
|
|
37
|
+
filters: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
export interface QueryOptions {
|
|
40
|
+
sorts: readonly string[];
|
|
41
|
+
filters: readonly string[];
|
|
42
|
+
defaultPageSize?: number;
|
|
43
|
+
maxPageSize?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class QueryError extends Error {
|
|
47
|
+
readonly code = 'invalid_query';
|
|
48
|
+
constructor(message: string) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'QueryError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pageSizes(options: QueryOptions) {
|
|
55
|
+
const max = options.maxPageSize ?? 100;
|
|
56
|
+
const size = options.defaultPageSize ?? 20;
|
|
57
|
+
if (
|
|
58
|
+
!Number.isInteger(max) ||
|
|
59
|
+
max < 1 ||
|
|
60
|
+
max > 100 ||
|
|
61
|
+
!Number.isInteger(size) ||
|
|
62
|
+
size < 1 ||
|
|
63
|
+
size > max
|
|
64
|
+
) {
|
|
65
|
+
throw new QueryError(
|
|
66
|
+
'Page size defaults must fit the supported range 1–100.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { max, size };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function integer(
|
|
73
|
+
value: number,
|
|
74
|
+
name: string,
|
|
75
|
+
max = Number.MAX_SAFE_INTEGER,
|
|
76
|
+
): number {
|
|
77
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
78
|
+
throw new QueryError(
|
|
79
|
+
`${name} must be an integer between 1 and ${max}.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function filterName(name: string): boolean {
|
|
86
|
+
return (
|
|
87
|
+
/^[a-zA-Z0-9_]+$/.test(name) &&
|
|
88
|
+
!['__proto__', 'prototype', 'constructor'].includes(name)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function normalizeListQuery(
|
|
93
|
+
query: ListQuery,
|
|
94
|
+
options: QueryOptions,
|
|
95
|
+
): ListQuery {
|
|
96
|
+
const { max } = pageSizes(options);
|
|
97
|
+
const page = integer(query.page, 'page');
|
|
98
|
+
const page_size = integer(query.page_size, 'page_size', max);
|
|
99
|
+
if (typeof query.search !== 'string')
|
|
100
|
+
throw new QueryError('Expected a search string.');
|
|
101
|
+
const sort = query.sort;
|
|
102
|
+
if (
|
|
103
|
+
sort !== null &&
|
|
104
|
+
(typeof sort !== 'object' ||
|
|
105
|
+
typeof sort.key !== 'string' ||
|
|
106
|
+
!sort.key ||
|
|
107
|
+
sort.key.startsWith('-') ||
|
|
108
|
+
!['asc', 'desc'].includes(sort.direction))
|
|
109
|
+
)
|
|
110
|
+
throw new QueryError(
|
|
111
|
+
'Expected a sort field and asc or desc direction.',
|
|
112
|
+
);
|
|
113
|
+
if (sort && !options.sorts.includes(sort.key))
|
|
114
|
+
throw new QueryError('Unsupported sort field.');
|
|
115
|
+
if (
|
|
116
|
+
!query.filters ||
|
|
117
|
+
typeof query.filters !== 'object' ||
|
|
118
|
+
Array.isArray(query.filters)
|
|
119
|
+
)
|
|
120
|
+
throw new QueryError('Expected a filter map.');
|
|
121
|
+
const filters: Record<string, string> = {};
|
|
122
|
+
for (const key of Object.keys(query.filters).sort()) {
|
|
123
|
+
if (!filterName(key) || !options.filters.includes(key))
|
|
124
|
+
throw new QueryError(
|
|
125
|
+
`Unsupported filter parameter: filter[${key}].`,
|
|
126
|
+
);
|
|
127
|
+
const value = query.filters[key];
|
|
128
|
+
if (typeof value !== 'string')
|
|
129
|
+
throw new QueryError(`Expected a string filter value: ${key}.`);
|
|
130
|
+
filters[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
page,
|
|
134
|
+
page_size,
|
|
135
|
+
search: query.search,
|
|
136
|
+
sort: sort ? { key: sort.key, direction: sort.direction } : null,
|
|
137
|
+
filters,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function decodeListQuery(
|
|
142
|
+
params: URLSearchParams,
|
|
143
|
+
options: QueryOptions,
|
|
144
|
+
): ListQuery {
|
|
145
|
+
const { max, size } = pageSizes(options);
|
|
146
|
+
for (const key of ['page', 'page_size', 'search', 'sort']) {
|
|
147
|
+
if (params.getAll(key).length > 1)
|
|
148
|
+
throw new QueryError(`Duplicate ${key} parameter.`);
|
|
149
|
+
}
|
|
150
|
+
const rawSort = params.get('sort') ?? '';
|
|
151
|
+
const sort = rawSort ? parseSort(rawSort) : null;
|
|
152
|
+
if (rawSort && (!sort || sort.key.startsWith('-')))
|
|
153
|
+
throw new QueryError('Unsupported sort field.');
|
|
154
|
+
const filters: Record<string, string> = {};
|
|
155
|
+
for (const [key, value] of params) {
|
|
156
|
+
if (
|
|
157
|
+
['page', 'page_size', 'search', 'sort'].some((name) =>
|
|
158
|
+
key.startsWith(`${name}[`),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
throw new QueryError(`Expected a scalar parameter: ${key}.`);
|
|
162
|
+
if (key !== 'filter' && !key.startsWith('filter[')) continue;
|
|
163
|
+
const match = /^filter\[([a-zA-Z0-9_]+)\]$/.exec(key);
|
|
164
|
+
const name = match?.[1];
|
|
165
|
+
if (!name || !filterName(name))
|
|
166
|
+
throw new QueryError(`Unsupported filter parameter: ${key}.`);
|
|
167
|
+
if (params.getAll(key).length !== 1)
|
|
168
|
+
throw new QueryError(`Duplicate filter: ${name}.`);
|
|
169
|
+
filters[name] = value;
|
|
170
|
+
}
|
|
171
|
+
function number(name: string, fallback: number, max: number): number {
|
|
172
|
+
const value = params.get(name);
|
|
173
|
+
if (value === null) return fallback;
|
|
174
|
+
if (!/^[1-9]\d*$/.test(value))
|
|
175
|
+
throw new QueryError(
|
|
176
|
+
`${name} must be an integer between 1 and ${max}.`,
|
|
177
|
+
);
|
|
178
|
+
return Number(value);
|
|
179
|
+
}
|
|
180
|
+
return normalizeListQuery(
|
|
181
|
+
{
|
|
182
|
+
page: number('page', 1, Number.MAX_SAFE_INTEGER),
|
|
183
|
+
page_size: number('page_size', size, max),
|
|
184
|
+
search: params.get('search') ?? '',
|
|
185
|
+
sort,
|
|
186
|
+
filters,
|
|
187
|
+
},
|
|
188
|
+
options,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function encodeListQuery(query: ListQuery): URLSearchParams {
|
|
193
|
+
const normalized = normalizeListQuery(query, {
|
|
194
|
+
sorts: query.sort ? [query.sort.key] : [],
|
|
195
|
+
filters: query.filters ? Object.keys(query.filters) : [],
|
|
196
|
+
});
|
|
197
|
+
const params = new URLSearchParams({
|
|
198
|
+
page: String(normalized.page),
|
|
199
|
+
page_size: String(normalized.page_size),
|
|
200
|
+
});
|
|
201
|
+
if (normalized.search) params.set('search', normalized.search);
|
|
202
|
+
if (normalized.sort) params.set('sort', formatSort(normalized.sort)!);
|
|
203
|
+
for (const [key, value] of Object.entries(normalized.filters))
|
|
204
|
+
params.set(`filter[${key}]`, value);
|
|
205
|
+
return params;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function cloneRecord<T>(record: T): T {
|
|
209
|
+
return JSON.parse(JSON.stringify(record)) as T;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function readPath(record: unknown, path: ValuePath): unknown {
|
|
213
|
+
let value = record;
|
|
214
|
+
for (const key of path) {
|
|
215
|
+
if (
|
|
216
|
+
value === null ||
|
|
217
|
+
typeof value !== 'object' ||
|
|
218
|
+
['__proto__', 'prototype', 'constructor'].includes(String(key))
|
|
219
|
+
)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (
|
|
222
|
+
Array.isArray(value) !== (typeof key === 'number') ||
|
|
223
|
+
!Object.hasOwn(value, key)
|
|
224
|
+
)
|
|
225
|
+
return undefined;
|
|
226
|
+
value = (value as Record<string | number, unknown>)[key];
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function updatePath<T extends object>(
|
|
232
|
+
record: T,
|
|
233
|
+
path: ValuePath,
|
|
234
|
+
value: unknown,
|
|
235
|
+
): T {
|
|
236
|
+
if (
|
|
237
|
+
!path.length ||
|
|
238
|
+
typeof path[0] !== 'string' ||
|
|
239
|
+
path.some((key) =>
|
|
240
|
+
typeof key === 'number'
|
|
241
|
+
? !Number.isInteger(key) || key < 0 || key > 4294967294
|
|
242
|
+
: !key ||
|
|
243
|
+
['__proto__', 'prototype', 'constructor'].includes(key),
|
|
244
|
+
)
|
|
245
|
+
) {
|
|
246
|
+
throw new TypeError(
|
|
247
|
+
'Expected a non-empty path of safe object keys and array indices.',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const copy = cloneRecord(record);
|
|
251
|
+
let target = copy as Record<string | number, unknown>;
|
|
252
|
+
for (let i = 0; i < path.length; i++) {
|
|
253
|
+
const key = path[i]!;
|
|
254
|
+
if (Array.isArray(target) !== (typeof key === 'number'))
|
|
255
|
+
throw new TypeError(
|
|
256
|
+
'Path does not match its object or array container.',
|
|
257
|
+
);
|
|
258
|
+
if (Array.isArray(target) && Number(key) > target.length)
|
|
259
|
+
throw new TypeError('Array paths cannot create sparse records.');
|
|
260
|
+
if (i === path.length - 1) {
|
|
261
|
+
target[key] = value === undefined ? undefined : cloneRecord(value);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
const next = target[key];
|
|
265
|
+
if (next === null || typeof next !== 'object')
|
|
266
|
+
target[key] = typeof path[i + 1] === 'number' ? [] : {};
|
|
267
|
+
target = target[key] as Record<string | number, unknown>;
|
|
268
|
+
}
|
|
269
|
+
return copy;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function errorsFor(
|
|
273
|
+
issues: readonly ValidationIssue[],
|
|
274
|
+
path: ValuePath,
|
|
275
|
+
): string[] {
|
|
276
|
+
return issues
|
|
277
|
+
.filter(
|
|
278
|
+
(issue) =>
|
|
279
|
+
issue.path.length === path.length &&
|
|
280
|
+
issue.path.every((part, index) => part === path[index]),
|
|
281
|
+
)
|
|
282
|
+
.flatMap((issue) => issue.messages);
|
|
283
|
+
}
|