@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/CONVENTIONS.md +91 -28
  3. package/README.md +66 -0
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/Toast.vue +3 -4
  8. package/components/feedback/toast/ToastHost.vue +165 -0
  9. package/components/feedback/toast/toast.ts +116 -0
  10. package/components/feedback/toast/types.ts +48 -0
  11. package/components/form/block-editor/BlockEditor.vue +455 -0
  12. package/components/form/block-editor/block-editor.css +149 -0
  13. package/components/form/block-editor/types.ts +15 -0
  14. package/components/form/checkbox/Checkbox.vue +10 -2
  15. package/components/form/combobox/Combobox.vue +32 -39
  16. package/components/form/combobox/combobox.css +1 -1
  17. package/components/form/date-input/DateInput.vue +10 -2
  18. package/components/form/date-input/date-input.css +2 -17
  19. package/components/form/form/Form.vue +10 -9
  20. package/components/form/form/form.css +2 -22
  21. package/components/form/input-otp/InputOTP.vue +1 -1
  22. package/components/form/input-otp/input-otp.css +1 -1
  23. package/components/form/label/label.css +2 -11
  24. package/components/form/number-field/NumberField.vue +10 -3
  25. package/components/form/number-field/number-field.css +3 -3
  26. package/components/form/radio-group/RadioGroup.vue +1 -1
  27. package/components/form/record-form/RecordFields.vue +128 -0
  28. package/components/form/record-form/RecordForm.vue +116 -0
  29. package/components/form/record-form/fields.ts +20 -0
  30. package/components/form/record-form/record-form.css +15 -0
  31. package/components/form/record-form/types.ts +28 -0
  32. package/components/form/select/Select.vue +13 -4
  33. package/components/form/select/select.css +6 -9
  34. package/components/form/slider/Slider.vue +1 -1
  35. package/components/form/switch/Switch.vue +1 -1
  36. package/components/form/tags-input/TagsInput.vue +17 -2
  37. package/components/form/tags-input/tags-input.css +16 -12
  38. package/components/form/text-input/TextInput.vue +10 -2
  39. package/components/form/text-input/text-input.css +10 -129
  40. package/components/form/textarea/Textarea.vue +10 -2
  41. package/components/form/textarea/textarea.css +3 -19
  42. package/components/layout/app-layout/AppLayout.vue +116 -0
  43. package/components/layout/app-layout/app-layout.css +115 -0
  44. package/components/layout/index/Index.vue +373 -0
  45. package/components/layout/index/index.css +157 -0
  46. package/components/layout/index/useIndex.ts +407 -0
  47. package/components/layout/table/DataTable.vue +14 -1
  48. package/components/layout/table/Table.vue +12 -0
  49. package/components/layout/table/table.css +2 -1
  50. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  51. package/components/navigation/breadcrumb/breadcrumb.css +20 -0
  52. package/components/navigation/sidebar/Sidebar.vue +11 -8
  53. package/components/navigation/sidebar/sidebar.css +23 -16
  54. package/components/navigation/tabs/Tabs.vue +6 -0
  55. package/components/navigation/tabs/tabs.css +7 -7
  56. package/composables/useMenu.ts +20 -27
  57. package/package.json +17 -2
  58. package/styles/0-settings/colors.css +10 -0
  59. package/styles/4-components/form-field.css +112 -0
  60. package/styles/4-components/index.css +1 -0
  61. package/styles/main.css +1 -0
  62. package/utils/a11y/focus.ts +9 -3
  63. package/utils/a11y/index.ts +5 -1
  64. package/utils/a11y/live-region.ts +2 -1
  65. package/utils/cms/index.ts +283 -0
  66. 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-base);
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: 2px solid transparent;
18
- margin-block-end: -1px;
19
- transition: color 0.12s ease;
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: 3px solid var(--outline-clr-base);
30
- outline-offset: 2px;
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: 0.4;
33
+ opacity: var(--opacity-disabled);
34
34
  cursor: not-allowed;
35
35
  }
36
36
  .pui-tabs__item:disabled:hover {
@@ -85,31 +85,30 @@ export function useMenu(
85
85
  return getItems().filter((it) => !isDisabled(it));
86
86
  }
87
87
 
88
- // Initial roving tabindex — every item starts at `-1`. The first
89
- // enabled item is promoted to `0` so a Tab into the menu (rare —
90
- // the trigger usually opens with explicit focus) lands somewhere.
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
- items.forEach((item, idx) => {
94
- if (isDisabled(item)) {
95
- item.setAttribute('tabindex', '-1');
96
- return;
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
- // Promote the focus target to `tabindex="0"` and demote the
105
- // rest to `-1` BEFORE focusing — focus on a `tabindex="-1"`
106
- // element works fine, but the rove must reflect the new state
107
- // so a subsequent Tab leaves the menu cleanly.
108
- const items = getItems();
109
- items.forEach((it) => {
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
- // Re-rove so only the freshly-focused item carries
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.5.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
@@ -5,4 +5,5 @@
5
5
  @import url('./1-reset/index.css');
6
6
  @import url('./2-base/index.css');
7
7
  @import url('./3-layout/index.css');
8
+ @import url('./4-components/index.css');
8
9
  @import url('./5-utilities/index.css');
@@ -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 = formEl.querySelector<HTMLElement>(
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)[0];
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;
@@ -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 { announce, type LiveRegionPriority } from './live-region.js';
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
- function ensureLiveRegion(): HTMLElement {
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
+ }