@pienter/ui 0.3.0 → 0.7.1
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 +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- 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.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- 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/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/pagination/PaginationFooter.astro +0 -24
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/menu.ts +0 -255
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- package/utils/controllers/toast.ts +0 -426
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:pienter:cms:backend:v1",
|
|
4
|
+
"title": "Pienter CMS backend contract v1",
|
|
5
|
+
"description": "Common response envelopes and normalized list query. Validate domain records and endpoint-specific sort/filter rules separately.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{ "$ref": "#/$defs/ListResponse" },
|
|
8
|
+
{ "$ref": "#/$defs/ItemResponse" },
|
|
9
|
+
{ "$ref": "#/$defs/ErrorResponse" },
|
|
10
|
+
{ "$ref": "#/$defs/ListQuery" }
|
|
11
|
+
],
|
|
12
|
+
"$defs": {
|
|
13
|
+
"Record": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"description": "A domain record whose properties are defined by the endpoint.",
|
|
16
|
+
"additionalProperties": true
|
|
17
|
+
},
|
|
18
|
+
"ListResponse": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["data", "meta"],
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"data": { "type": "array", "items": { "$ref": "#/$defs/Record" } },
|
|
24
|
+
"meta": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["page", "page_size", "total"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"page": { "$ref": "#/$defs/Page" },
|
|
30
|
+
"page_size": { "$ref": "#/$defs/PageSize" },
|
|
31
|
+
"total": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 0,
|
|
34
|
+
"maximum": 9007199254740991
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"ItemResponse": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["data"],
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"properties": { "data": { "$ref": "#/$defs/Record" } }
|
|
45
|
+
},
|
|
46
|
+
"ErrorResponse": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"required": ["error"],
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"properties": {
|
|
51
|
+
"error": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["code", "message"],
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"properties": {
|
|
56
|
+
"code": { "type": "string", "minLength": 1 },
|
|
57
|
+
"message": { "type": "string", "minLength": 1 },
|
|
58
|
+
"issues": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"items": { "$ref": "#/$defs/ValidationIssue" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"ValidationIssue": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"required": ["path", "messages"],
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"properties": {
|
|
71
|
+
"path": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"description": "Object keys and zero-based array positions in the submitted value; an empty array addresses the whole value.",
|
|
74
|
+
"items": {
|
|
75
|
+
"oneOf": [
|
|
76
|
+
{ "type": "string", "minLength": 1 },
|
|
77
|
+
{ "type": "integer", "minimum": 0, "maximum": 9007199254740991 }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"messages": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"minItems": 1,
|
|
84
|
+
"items": { "type": "string", "minLength": 1 }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"ListQuery": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"description": "Normalized output from decoding URL query parameters, after defaults are applied. This is not a JSON request body.",
|
|
91
|
+
"required": ["page", "page_size", "search", "sort", "filters"],
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"properties": {
|
|
94
|
+
"page": { "$ref": "#/$defs/Page", "default": 1 },
|
|
95
|
+
"page_size": { "$ref": "#/$defs/PageSize", "default": 20 },
|
|
96
|
+
"search": { "type": "string", "default": "" },
|
|
97
|
+
"sort": {
|
|
98
|
+
"default": null,
|
|
99
|
+
"oneOf": [
|
|
100
|
+
{ "type": "null" },
|
|
101
|
+
{
|
|
102
|
+
"type": "object",
|
|
103
|
+
"required": ["key", "direction"],
|
|
104
|
+
"additionalProperties": false,
|
|
105
|
+
"properties": {
|
|
106
|
+
"key": { "type": "string", "minLength": 1 },
|
|
107
|
+
"direction": { "enum": ["asc", "desc"] }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"filters": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"default": {},
|
|
115
|
+
"propertyNames": {
|
|
116
|
+
"pattern": "^[a-zA-Z0-9_]+$",
|
|
117
|
+
"not": { "enum": ["__proto__", "prototype", "constructor"] }
|
|
118
|
+
},
|
|
119
|
+
"additionalProperties": { "type": "string" }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"Page": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
|
|
124
|
+
"PageSize": { "type": "integer", "minimum": 1, "maximum": 100 }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar primitives, applied reactively by Sidebar.vue. See ADR 0007.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** A group's expanded/flyout state, as the templates render it. */
|
|
6
|
+
export type SubmenuState = 'open' | 'closed';
|
|
7
|
+
|
|
8
|
+
/** Grace period before an unpinned flyout closes on pointer-out. */
|
|
9
|
+
export const SUBMENU_CLOSE_DELAY = 140;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hierarchical-href match — locked algorithm. Returns true when
|
|
13
|
+
* `itemHref` is the prefix of `activeHref` honoring path boundaries.
|
|
14
|
+
*
|
|
15
|
+
* - `/` (root) is special-cased to strict equality so it doesn't
|
|
16
|
+
* greedily match every URL.
|
|
17
|
+
* - Substring-only matches are rejected (`/admin/members-old` does
|
|
18
|
+
* NOT match `/admin/members`).
|
|
19
|
+
*
|
|
20
|
+
* See CONVENTIONS.md § Active-href hierarchical-match algorithm.
|
|
21
|
+
*/
|
|
22
|
+
export function isHrefActive(
|
|
23
|
+
activeHref: string | null | undefined,
|
|
24
|
+
itemHref: string,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!activeHref) return false;
|
|
27
|
+
if (itemHref === '/') return activeHref === '/';
|
|
28
|
+
if (activeHref === itemHref) return true;
|
|
29
|
+
return activeHref.startsWith(itemHref + '/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Kebab-case slug used as a group's key when none was supplied. */
|
|
33
|
+
export function deriveGroupKey(label: string): string {
|
|
34
|
+
return label
|
|
35
|
+
.normalize('NFKD')
|
|
36
|
+
.replace(/[̀-ͯ]/g, '')
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
39
|
+
.replace(/^-+|-+$/g, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Viewport coordinates for a collapsed-rail flyout. */
|
|
43
|
+
export interface SubmenuPosition {
|
|
44
|
+
left: number;
|
|
45
|
+
top: number;
|
|
46
|
+
height: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where a collapsed rail's flyout sits: flush with the rail, starting below
|
|
51
|
+
* the brand if there is one, running the column's full height. Physical
|
|
52
|
+
* `left` because RTL is deferred library-wide.
|
|
53
|
+
*/
|
|
54
|
+
export function computeSubmenuPosition(
|
|
55
|
+
railRect: DOMRect,
|
|
56
|
+
brandRect: DOMRect | null,
|
|
57
|
+
): SubmenuPosition {
|
|
58
|
+
const top = brandRect ? brandRect.bottom : railRect.top;
|
|
59
|
+
return {
|
|
60
|
+
left: railRect.right,
|
|
61
|
+
top,
|
|
62
|
+
height: Math.max(0, railRect.bottom - top),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Below the drawer breakpoint there is no rail, so no flyouts. */
|
|
67
|
+
export function isSubmenuActive(options: {
|
|
68
|
+
policy: 'hidden' | 'panel';
|
|
69
|
+
collapsed: boolean;
|
|
70
|
+
viewportWidth: number;
|
|
71
|
+
drawerBreakpoint: number;
|
|
72
|
+
}): boolean {
|
|
73
|
+
return (
|
|
74
|
+
options.policy === 'panel' &&
|
|
75
|
+
options.collapsed &&
|
|
76
|
+
options.viewportWidth >= options.drawerBreakpoint
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a group's subtree contains the current page. */
|
|
81
|
+
export function groupContainsActive(
|
|
82
|
+
items: readonly SidebarItemLike[],
|
|
83
|
+
activeHref: string | null | undefined,
|
|
84
|
+
): boolean {
|
|
85
|
+
return items.some((item) => {
|
|
86
|
+
if (item.type === 'link') return isHrefActive(activeHref, item.href);
|
|
87
|
+
if (item.type === 'group')
|
|
88
|
+
return groupContainsActive(item.items, activeHref);
|
|
89
|
+
return false;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The part of `SidebarItem` these primitives read; avoids a component import. */
|
|
94
|
+
export type SidebarItemLike =
|
|
95
|
+
| { type: 'link'; href: string }
|
|
96
|
+
| { type: 'group'; items: readonly SidebarItemLike[] }
|
|
97
|
+
| { type: 'section' };
|
package/utils/validation/form.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { type Validator, validate } from './rules.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Placeholder shape for form-submit responses, consumed
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* shape is **subject to change when ts-sdk publishes the canonical
|
|
9
|
-
* response shape**, at which point both implementations are updated
|
|
10
|
-
* together.
|
|
4
|
+
* Placeholder shape for form-submit responses, consumed by `Form.vue`'s
|
|
5
|
+
* orchestration. Locked in design D13 / spec "Unified Form Submit Response
|
|
6
|
+
* Handling"; the exact shape is **subject to change when ts-sdk publishes
|
|
7
|
+
* the canonical response shape**.
|
|
11
8
|
*/
|
|
12
9
|
export interface FormSubmitResponse {
|
|
13
10
|
/** Whether the submit succeeded. */
|
|
@@ -28,8 +25,8 @@ export interface FormSubmitResponse {
|
|
|
28
25
|
|
|
29
26
|
/**
|
|
30
27
|
* Run a per-field validator map against a `FormData` instance and return
|
|
31
|
-
* a `Record<string, string[]>` of field errors. Pure function;
|
|
32
|
-
*
|
|
28
|
+
* a `Record<string, string[]>` of field errors. Pure function; used by
|
|
29
|
+
* Form.vue's orchestration.
|
|
33
30
|
*
|
|
34
31
|
* Field values are read via `formData.get(name)` and coerced to strings.
|
|
35
32
|
* Fields without an entry in `validators` are not checked. Fields whose
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import './button.css';
|
|
3
|
-
import Icon from '../../display/icon/Icon.astro';
|
|
4
|
-
import Spinner from '../../feedback/spinner/Spinner.astro';
|
|
5
|
-
import type { IconName } from '../../../icons/index.js';
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
|
|
9
|
-
size?: 'sm' | 'md';
|
|
10
|
-
disabled?: boolean;
|
|
11
|
-
loading?: boolean;
|
|
12
|
-
type?: 'button' | 'submit' | 'reset';
|
|
13
|
-
block?: boolean;
|
|
14
|
-
href?: string;
|
|
15
|
-
/** Leading icon from the shipped set. Use the `icon` slot for a custom <svg>. */
|
|
16
|
-
icon?: IconName;
|
|
17
|
-
/** Anchor target, only applied when `href` is set (renders an <a>). */
|
|
18
|
-
target?: string;
|
|
19
|
-
/** Anchor rel; defaults to a safe value for `target="_blank"`. */
|
|
20
|
-
rel?: string;
|
|
21
|
-
id?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const {
|
|
25
|
-
variant = 'secondary',
|
|
26
|
-
size = 'md',
|
|
27
|
-
disabled = false,
|
|
28
|
-
loading = false,
|
|
29
|
-
type = 'button',
|
|
30
|
-
block = false,
|
|
31
|
-
href,
|
|
32
|
-
icon,
|
|
33
|
-
target,
|
|
34
|
-
rel,
|
|
35
|
-
id,
|
|
36
|
-
...rest
|
|
37
|
-
} = Astro.props;
|
|
38
|
-
|
|
39
|
-
const Tag = href ? 'a' : 'button';
|
|
40
|
-
const isInert = disabled || loading;
|
|
41
|
-
// Button tier -> Icon size for the leading glyph (the Spinner is sized in button.css)
|
|
42
|
-
const iconSize = size === 'sm' ? 'sm' : 'md';
|
|
43
|
-
const hasIconSlot = Astro.slots.has('icon');
|
|
44
|
-
const hasIcon = !!icon || hasIconSlot;
|
|
45
|
-
const resolvedRel = href
|
|
46
|
-
? (rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined))
|
|
47
|
-
: undefined;
|
|
48
|
-
|
|
49
|
-
// the icon slot wins over the `icon` name; flag the redundant combination in dev
|
|
50
|
-
if (import.meta.env.DEV && icon && hasIconSlot) {
|
|
51
|
-
console.warn(
|
|
52
|
-
'[pienter-ui] Button received both an `icon` name and an `icon` slot; the slot is used and `icon` is ignored.',
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
<Tag
|
|
58
|
-
id={id}
|
|
59
|
-
class="pui-btn"
|
|
60
|
-
type={href ? undefined : type}
|
|
61
|
-
href={href}
|
|
62
|
-
target={href ? target : undefined}
|
|
63
|
-
rel={resolvedRel}
|
|
64
|
-
disabled={href ? undefined : isInert}
|
|
65
|
-
aria-disabled={href && isInert ? 'true' : undefined}
|
|
66
|
-
tabindex={href && isInert ? '-1' : undefined}
|
|
67
|
-
aria-busy={loading ? 'true' : undefined}
|
|
68
|
-
data-variant={variant}
|
|
69
|
-
data-size={size}
|
|
70
|
-
data-block={block ? 'true' : undefined}
|
|
71
|
-
data-state={loading ? 'loading' : undefined}
|
|
72
|
-
{...rest}
|
|
73
|
-
>
|
|
74
|
-
{
|
|
75
|
-
loading && (
|
|
76
|
-
<span class="pui-btn__spinner" aria-hidden="true">
|
|
77
|
-
<Spinner />
|
|
78
|
-
</span>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
{
|
|
82
|
-
hasIcon && (
|
|
83
|
-
<span class="pui-btn__icon" aria-hidden="true">
|
|
84
|
-
{hasIconSlot ? (
|
|
85
|
-
<Icon size={iconSize}>
|
|
86
|
-
<slot name="icon" />
|
|
87
|
-
</Icon>
|
|
88
|
-
) : (
|
|
89
|
-
<Icon name={icon} size={iconSize} />
|
|
90
|
-
)}
|
|
91
|
-
</span>
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
<span class="pui-btn__label"><slot /></span>
|
|
95
|
-
</Tag>
|