@tai42/studio-sdk 21.0.0 → 22.0.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/dist/components/chip-group.d.ts +42 -0
- package/dist/components/chip-group.d.ts.map +1 -0
- package/dist/components/chip-group.js +85 -0
- package/dist/components/chip-group.js.map +1 -0
- package/dist/components/components.css +9 -3
- package/dist/hooks/useApi.d.ts +268 -46
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/schema-form/SchemaForm.d.ts +11 -1
- package/dist/schema-form/SchemaForm.d.ts.map +1 -1
- package/dist/schema-form/SchemaForm.js +2 -2
- package/dist/schema-form/SchemaForm.js.map +1 -1
- package/dist/schema-form/classify.d.ts.map +1 -1
- package/dist/schema-form/classify.js +54 -2
- package/dist/schema-form/classify.js.map +1 -1
- package/dist/schema-form/date-range-field.d.ts +44 -0
- package/dist/schema-form/date-range-field.d.ts.map +1 -0
- package/dist/schema-form/date-range-field.js +72 -0
- package/dist/schema-form/date-range-field.js.map +1 -0
- package/dist/schema-form/enum-field.d.ts +3 -1
- package/dist/schema-form/enum-field.d.ts.map +1 -1
- package/dist/schema-form/enum-field.js +8 -2
- package/dist/schema-form/enum-field.js.map +1 -1
- package/dist/schema-form/field-model.d.ts +20 -1
- package/dist/schema-form/field-model.d.ts.map +1 -1
- package/dist/schema-form/field-node.d.ts +10 -0
- package/dist/schema-form/field-node.d.ts.map +1 -1
- package/dist/schema-form/field-node.js +6 -3
- package/dist/schema-form/field-node.js.map +1 -1
- package/dist/schema-form/index.d.ts +3 -1
- package/dist/schema-form/index.d.ts.map +1 -1
- package/dist/schema-form/index.js +1 -0
- package/dist/schema-form/index.js.map +1 -1
- package/dist/schema-form/multiselect-field.d.ts.map +1 -1
- package/dist/schema-form/multiselect-field.js +11 -0
- package/dist/schema-form/multiselect-field.js.map +1 -1
- package/dist/schema-form/object-fields.d.ts +9 -1
- package/dist/schema-form/object-fields.d.ts.map +1 -1
- package/dist/schema-form/object-fields.js +16 -3
- package/dist/schema-form/object-fields.js.map +1 -1
- package/dist/schema-form/string-field.d.ts +6 -1
- package/dist/schema-form/string-field.d.ts.map +1 -1
- package/dist/schema-form/string-field.js +42 -8
- package/dist/schema-form/string-field.js.map +1 -1
- package/dist/schema-form/types.d.ts +36 -0
- package/dist/schema-form/types.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/components/chip-group.tsx +157 -0
- package/src/components/components.css +9 -3
- package/src/index.ts +6 -0
- package/src/schema-form/SchemaForm.tsx +10 -0
- package/src/schema-form/classify.ts +59 -3
- package/src/schema-form/date-range-field.tsx +167 -0
- package/src/schema-form/enum-field.tsx +20 -0
- package/src/schema-form/field-model.ts +24 -2
- package/src/schema-form/field-node.tsx +19 -0
- package/src/schema-form/index.ts +10 -1
- package/src/schema-form/multiselect-field.tsx +21 -0
- package/src/schema-form/object-fields.tsx +36 -2
- package/src/schema-form/string-field.tsx +72 -1
- package/src/schema-form/types.ts +38 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChipGroup` — a tap-to-select grid of pill chips (`.tai-chip`), in two modes.
|
|
3
|
+
*
|
|
4
|
+
* `single` is a `role="radiogroup"` of `role="radio"` chips carrying `aria-checked`:
|
|
5
|
+
* exactly one value is selected, the arrow keys move the selection, and only the
|
|
6
|
+
* selected chip (or the first, when none is) sits in the tab order. `multi` is a
|
|
7
|
+
* group of toggle buttons carrying `aria-pressed`: any number are selected, the
|
|
8
|
+
* arrow keys move focus, and Space/Enter (the native button activation) toggles the
|
|
9
|
+
* focused chip.
|
|
10
|
+
*
|
|
11
|
+
* Roving tabindex and arrow-key movement are owned here — there is no multi-select
|
|
12
|
+
* primitive to borrow — so both modes share one keyboard model: ←/↑ to the previous
|
|
13
|
+
* chip, →/↓ to the next (wrapping), Home/End to the ends, skipping disabled chips.
|
|
14
|
+
* The handler rides each chip (a button, an interactive element) rather than the
|
|
15
|
+
* container, so no static element carries an interaction. Like `RadioGroup`, the
|
|
16
|
+
* group takes its accessible name from an enclosing group `Field`, not a label of
|
|
17
|
+
* its own.
|
|
18
|
+
*/
|
|
19
|
+
import type { KeyboardEvent, ReactNode } from 'react';
|
|
20
|
+
import { useRef, useState } from 'react';
|
|
21
|
+
|
|
22
|
+
import { useFieldControl } from './field';
|
|
23
|
+
|
|
24
|
+
/** One selectable chip: its answer `value` and the visible `label`. */
|
|
25
|
+
export interface ChipOption {
|
|
26
|
+
readonly value: string;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
readonly disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ChipGroupProps =
|
|
32
|
+
| {
|
|
33
|
+
/** Exactly one value selected — a `radiogroup` of radios. */
|
|
34
|
+
readonly mode: 'single';
|
|
35
|
+
readonly options: readonly ChipOption[];
|
|
36
|
+
readonly value: string;
|
|
37
|
+
readonly onValueChange: (value: string) => void;
|
|
38
|
+
readonly disabled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
/** Any number selected — a group of `aria-pressed` toggle buttons. */
|
|
42
|
+
readonly mode: 'multi';
|
|
43
|
+
readonly options: readonly ChipOption[];
|
|
44
|
+
readonly value: readonly string[];
|
|
45
|
+
readonly onValueChange: (value: readonly string[]) => void;
|
|
46
|
+
readonly disabled?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function ChipGroup(props: ChipGroupProps): ReactNode {
|
|
50
|
+
const { options, disabled = false } = props;
|
|
51
|
+
const field = useFieldControl();
|
|
52
|
+
const chipRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
53
|
+
|
|
54
|
+
const isSelected = (option: ChipOption): boolean =>
|
|
55
|
+
props.mode === 'single' ? props.value === option.value : props.value.includes(option.value);
|
|
56
|
+
|
|
57
|
+
// The chip in the tab order: the (first) selected one, else the first enabled
|
|
58
|
+
// one — so Tab lands on the active choice and the arrows rove from there.
|
|
59
|
+
const [rovingIndex, setRovingIndex] = useState<number>(() => {
|
|
60
|
+
const selected = options.findIndex(isSelected);
|
|
61
|
+
if (selected >= 0) return selected;
|
|
62
|
+
const firstEnabled = options.findIndex((option) => option.disabled !== true);
|
|
63
|
+
return firstEnabled >= 0 ? firstEnabled : 0;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const enabledIndices = options
|
|
67
|
+
.map((option, index) => (option.disabled === true ? -1 : index))
|
|
68
|
+
.filter((index) => index >= 0);
|
|
69
|
+
|
|
70
|
+
const focusChip = (index: number): void => {
|
|
71
|
+
setRovingIndex(index);
|
|
72
|
+
chipRefs.current[index]?.focus();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const select = (option: ChipOption): void => {
|
|
76
|
+
if (props.mode === 'single') {
|
|
77
|
+
props.onValueChange(option.value);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const chosen = new Set(props.value);
|
|
81
|
+
if (chosen.has(option.value)) chosen.delete(option.value);
|
|
82
|
+
else chosen.add(option.value);
|
|
83
|
+
// Keep the OPTIONS' declared order, so the emitted array reads the way the form
|
|
84
|
+
// lists the choices rather than the order the chips were pressed.
|
|
85
|
+
props.onValueChange(options.map((candidate) => candidate.value).filter((v) => chosen.has(v)));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const onChipKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
|
89
|
+
if (disabled || enabledIndices.length === 0) return;
|
|
90
|
+
const position = enabledIndices.indexOf(rovingIndex);
|
|
91
|
+
let nextPosition: number;
|
|
92
|
+
switch (event.key) {
|
|
93
|
+
case 'ArrowRight':
|
|
94
|
+
case 'ArrowDown':
|
|
95
|
+
nextPosition = (position + 1) % enabledIndices.length;
|
|
96
|
+
break;
|
|
97
|
+
case 'ArrowLeft':
|
|
98
|
+
case 'ArrowUp':
|
|
99
|
+
nextPosition = (position - 1 + enabledIndices.length) % enabledIndices.length;
|
|
100
|
+
break;
|
|
101
|
+
case 'Home':
|
|
102
|
+
nextPosition = 0;
|
|
103
|
+
break;
|
|
104
|
+
case 'End':
|
|
105
|
+
nextPosition = enabledIndices.length - 1;
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
const nextIndex = enabledIndices[nextPosition];
|
|
112
|
+
if (nextIndex === undefined) return;
|
|
113
|
+
const nextOption = options[nextIndex];
|
|
114
|
+
if (nextOption === undefined) return;
|
|
115
|
+
focusChip(nextIndex);
|
|
116
|
+
// A radiogroup selects as it moves; a multi-select group only moves focus and
|
|
117
|
+
// leaves Space/Enter to toggle.
|
|
118
|
+
if (props.mode === 'single') props.onValueChange(nextOption.value);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
role={props.mode === 'single' ? 'radiogroup' : 'group'}
|
|
124
|
+
className="tai-row"
|
|
125
|
+
aria-describedby={field['aria-describedby']}
|
|
126
|
+
>
|
|
127
|
+
{options.map((option, index) => {
|
|
128
|
+
const selected = isSelected(option);
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
key={option.value}
|
|
132
|
+
ref={(element) => {
|
|
133
|
+
chipRefs.current[index] = element;
|
|
134
|
+
}}
|
|
135
|
+
type="button"
|
|
136
|
+
className="tai-chip"
|
|
137
|
+
role={props.mode === 'single' ? 'radio' : undefined}
|
|
138
|
+
aria-checked={props.mode === 'single' ? selected : undefined}
|
|
139
|
+
aria-pressed={props.mode === 'multi' ? selected : undefined}
|
|
140
|
+
// `role="group"` does not support `aria-invalid` (ARIA 1.2), so the
|
|
141
|
+
// chips carry the enclosing errored Field's invalid state.
|
|
142
|
+
aria-invalid={field['aria-invalid']}
|
|
143
|
+
tabIndex={index === rovingIndex ? 0 : -1}
|
|
144
|
+
disabled={disabled || option.disabled === true}
|
|
145
|
+
onClick={() => {
|
|
146
|
+
focusChip(index);
|
|
147
|
+
select(option);
|
|
148
|
+
}}
|
|
149
|
+
onKeyDown={onChipKeyDown}
|
|
150
|
+
>
|
|
151
|
+
{option.label}
|
|
152
|
+
</button>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -1061,11 +1061,16 @@
|
|
|
1061
1061
|
color var(--tai-motion-fast) ease;
|
|
1062
1062
|
}
|
|
1063
1063
|
|
|
1064
|
-
.tai-chip:not(.tai-chip-static):not([aria-pressed='true']):hover:not(
|
|
1064
|
+
.tai-chip:not(.tai-chip-static):not([aria-pressed='true']):not([aria-checked='true']):hover:not(
|
|
1065
|
+
:disabled
|
|
1066
|
+
) {
|
|
1065
1067
|
background: var(--tai-color-surface);
|
|
1066
1068
|
}
|
|
1067
1069
|
|
|
1068
|
-
|
|
1070
|
+
/* A selected chip, pressed (a multi-select toggle) OR checked (a single-select
|
|
1071
|
+
radio chip): both wear the accent tint. */
|
|
1072
|
+
.tai-chip[aria-pressed='true'],
|
|
1073
|
+
.tai-chip[aria-checked='true'] {
|
|
1069
1074
|
background: var(--tai-color-accent-tint);
|
|
1070
1075
|
border-color: var(--tai-color-accent);
|
|
1071
1076
|
color: var(--tai-color-accent-on-tint);
|
|
@@ -1073,7 +1078,8 @@
|
|
|
1073
1078
|
|
|
1074
1079
|
/* A selected chip KEEPS its tint on hover and strengthens its border instead —
|
|
1075
1080
|
the one part that moves in both themes (see the segment rule above). */
|
|
1076
|
-
.tai-chip[aria-pressed='true']:hover:not(:disabled)
|
|
1081
|
+
.tai-chip[aria-pressed='true']:hover:not(:disabled),
|
|
1082
|
+
.tai-chip[aria-checked='true']:hover:not(:disabled) {
|
|
1077
1083
|
border-color: var(--tai-color-accent-hover);
|
|
1078
1084
|
}
|
|
1079
1085
|
|
package/src/index.ts
CHANGED
|
@@ -171,6 +171,8 @@ export type { BadgeProps } from './components/badge';
|
|
|
171
171
|
export { Badge } from './components/badge';
|
|
172
172
|
export type { CheckboxProps } from './components/checkbox';
|
|
173
173
|
export { Checkbox } from './components/checkbox';
|
|
174
|
+
export type { ChipGroupProps, ChipOption } from './components/chip-group';
|
|
175
|
+
export { ChipGroup } from './components/chip-group';
|
|
174
176
|
export type { ConfirmDialogProps } from './components/confirm-dialog';
|
|
175
177
|
export { ConfirmDialog } from './components/confirm-dialog';
|
|
176
178
|
export type { DialogProps } from './components/dialog';
|
|
@@ -413,11 +415,14 @@ export {
|
|
|
413
415
|
// -- Schema-driven forms -----------------------------------------------------
|
|
414
416
|
export type {
|
|
415
417
|
CompletionProvider,
|
|
418
|
+
DateRangePair,
|
|
416
419
|
Discriminator,
|
|
417
420
|
ExpressionFieldComponent,
|
|
418
421
|
ExpressionFieldProps,
|
|
419
422
|
ExpressionInputKey,
|
|
420
423
|
ExpressionInputShape,
|
|
424
|
+
FormControl,
|
|
425
|
+
FormInputMode,
|
|
421
426
|
JsonSchema,
|
|
422
427
|
JsonSchemaType,
|
|
423
428
|
RecordEntryContext,
|
|
@@ -428,6 +433,7 @@ export type {
|
|
|
428
433
|
SecretRefFieldProps,
|
|
429
434
|
} from './schema-form';
|
|
430
435
|
export {
|
|
436
|
+
dateRangePairs,
|
|
431
437
|
defaultValueForSchema,
|
|
432
438
|
// The expression-door injection point: a host that wants `x-tai42-expression`
|
|
433
439
|
// fields to author through the visual editor imports `JqField` from
|
|
@@ -95,6 +95,14 @@ export interface SchemaFormProps {
|
|
|
95
95
|
* renders the plain string input.
|
|
96
96
|
*/
|
|
97
97
|
readonly expressionField?: ExpressionFieldComponent;
|
|
98
|
+
/**
|
|
99
|
+
* Optional. Per-field picker placeholders, keyed by the root object's property
|
|
100
|
+
* name. A top-level `enum` field that draws as a select shows its entry's
|
|
101
|
+
* `choiceLabel` as the select placeholder (in place of the default `Select…`);
|
|
102
|
+
* other controls and nested fields are unaffected. A field with no entry draws
|
|
103
|
+
* its own default.
|
|
104
|
+
*/
|
|
105
|
+
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
/**
|
|
@@ -110,6 +118,7 @@ export function SchemaForm({
|
|
|
110
118
|
completionProvider,
|
|
111
119
|
maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
|
|
112
120
|
expressionField,
|
|
121
|
+
fieldWording,
|
|
113
122
|
}: SchemaFormProps): ReactNode {
|
|
114
123
|
// The prop overrides the ambient door for this form only; omitted, the
|
|
115
124
|
// enclosing one flows through unchanged (providing `undefined` would BLIND the
|
|
@@ -130,6 +139,7 @@ export function SchemaForm({
|
|
|
130
139
|
required
|
|
131
140
|
errors={errors}
|
|
132
141
|
idPrefix={idPrefix}
|
|
142
|
+
fieldWording={fieldWording}
|
|
133
143
|
/>
|
|
134
144
|
</div>
|
|
135
145
|
</ExpressionFieldContext.Provider>
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
variantTag,
|
|
20
20
|
} from './resolve';
|
|
21
21
|
import { expressionAnnotation, mediaUpload } from './string-annotations';
|
|
22
|
-
import type { JsonSchema } from './types';
|
|
22
|
+
import type { FormControl, FormInputMode, JsonSchema } from './types';
|
|
23
23
|
|
|
24
24
|
function firstString(...values: readonly unknown[]): string | undefined {
|
|
25
25
|
for (const value of values) {
|
|
@@ -64,6 +64,27 @@ function classifyConst(
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The supported `control` values, as a record so the type and the runtime set
|
|
68
|
+
// stay coupled: a value added to FormControl is a compile error here until it is
|
|
69
|
+
// listed.
|
|
70
|
+
const FORM_CONTROLS: Record<FormControl, true> = {
|
|
71
|
+
chips: true,
|
|
72
|
+
textarea: true,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `control` keyword off a property, narrowed to a supported value, or
|
|
77
|
+
* `undefined` for an absent or unrecognized one. Read defensively (the schema is
|
|
78
|
+
* permissive): a value outside the set reads as absent, so the field renders its
|
|
79
|
+
* default control — the server ask door is the authority that refuses it.
|
|
80
|
+
*/
|
|
81
|
+
function controlOf(resolved: JsonSchema): FormControl | undefined {
|
|
82
|
+
const raw: unknown = resolved.control;
|
|
83
|
+
return typeof raw === 'string' && Object.hasOwn(FORM_CONTROLS, raw)
|
|
84
|
+
? (raw as FormControl)
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
// enum → a fixed choice list.
|
|
68
89
|
function classifyEnum(
|
|
69
90
|
resolved: JsonSchema,
|
|
@@ -73,7 +94,13 @@ function classifyEnum(
|
|
|
73
94
|
const enumValues = resolved.enum ?? [];
|
|
74
95
|
const options: EnumOption[] = enumValues.map((value) => ({ value, label: scalarLabel(value) }));
|
|
75
96
|
const nullable = enumValues.some((value) => value === null);
|
|
76
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
model: { kind: 'enum', options, control: controlOf(resolved) },
|
|
99
|
+
nullable,
|
|
100
|
+
schema: resolved,
|
|
101
|
+
title,
|
|
102
|
+
description,
|
|
103
|
+
};
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
// anyOf/oneOf union normalization.
|
|
@@ -214,6 +241,31 @@ function dateConstraints(resolved: JsonSchema): DateConstraints | undefined {
|
|
|
214
241
|
return { min, max, unavailable, rangeStart, minDays, maxDays };
|
|
215
242
|
}
|
|
216
243
|
|
|
244
|
+
// The supported `inputMode` values, as a record so the type and the runtime set
|
|
245
|
+
// stay coupled: a value added to FormInputMode is a compile error here until it
|
|
246
|
+
// is listed.
|
|
247
|
+
const INPUT_MODES: Record<FormInputMode, true> = {
|
|
248
|
+
text: true,
|
|
249
|
+
numeric: true,
|
|
250
|
+
decimal: true,
|
|
251
|
+
tel: true,
|
|
252
|
+
email: true,
|
|
253
|
+
url: true,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The `inputMode` keyword off a string property, narrowed to a supported value, or
|
|
258
|
+
* `undefined` for an absent or unrecognized one. Read defensively (the schema is
|
|
259
|
+
* permissive): a value outside the set reads as absent, so the field renders as a
|
|
260
|
+
* plain text box — the server ask door is the authority that refuses it.
|
|
261
|
+
*/
|
|
262
|
+
function inputModeOf(resolved: JsonSchema): FormInputMode | undefined {
|
|
263
|
+
const raw: unknown = resolved.inputMode;
|
|
264
|
+
return typeof raw === 'string' && Object.hasOwn(INPUT_MODES, raw)
|
|
265
|
+
? (raw as FormInputMode)
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
217
269
|
function classifyString(
|
|
218
270
|
resolved: JsonSchema,
|
|
219
271
|
title: string | undefined,
|
|
@@ -224,6 +276,10 @@ function classifyString(
|
|
|
224
276
|
model: {
|
|
225
277
|
kind: 'string',
|
|
226
278
|
format: resolved.format,
|
|
279
|
+
inputMode: inputModeOf(resolved),
|
|
280
|
+
multiline: controlOf(resolved) === 'textarea',
|
|
281
|
+
maxLength: typeof resolved.maxLength === 'number' ? resolved.maxLength : undefined,
|
|
282
|
+
minLength: typeof resolved.minLength === 'number' ? resolved.minLength : undefined,
|
|
227
283
|
media: mediaUpload(resolved),
|
|
228
284
|
expression: expressionAnnotation(resolved),
|
|
229
285
|
date: dateConstraints(resolved),
|
|
@@ -264,7 +320,7 @@ function classifyArray(
|
|
|
264
320
|
if (isStringItems && enumValues !== undefined && enumValues.length > 0) {
|
|
265
321
|
const options = enumValues.map((value) => ({ value, label: scalarLabel(value) }));
|
|
266
322
|
return {
|
|
267
|
-
model: { kind: 'multiselect', options },
|
|
323
|
+
model: { kind: 'multiselect', options, control: controlOf(resolved) },
|
|
268
324
|
nullable,
|
|
269
325
|
schema: resolved,
|
|
270
326
|
title,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A date RANGE drawn as ONE control. The platform declares a range as two
|
|
3
|
+
* `string`/`format: date` properties where the END names its START in `rangeStart`;
|
|
4
|
+
* the answer stays the two date strings, nothing is recombined. Where a pair is
|
|
5
|
+
* drawable as one control (below), `ObjectFields` renders this {@link DateRangeField}
|
|
6
|
+
* — a group with the two ends' labels over two native date inputs — at the START's
|
|
7
|
+
* slot and skips the END; otherwise the two properties draw as two ordinary date
|
|
8
|
+
* fields (a documented, valid layout).
|
|
9
|
+
*
|
|
10
|
+
* {@link dateRangePairs} is the ONE definition of "drawable as one control", reused
|
|
11
|
+
* by every surface so each surface's merge decision agrees.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
14
|
+
|
|
15
|
+
import { Field } from '../components/field';
|
|
16
|
+
import { TextInput } from '../components/inputs';
|
|
17
|
+
import type { JsonSchema } from './types';
|
|
18
|
+
|
|
19
|
+
/** A START/END pair drawable as one range control, resolved off a page's properties. */
|
|
20
|
+
export interface DateRangePair {
|
|
21
|
+
readonly startName: string;
|
|
22
|
+
readonly startSchema: JsonSchema;
|
|
23
|
+
readonly endName: string;
|
|
24
|
+
readonly endSchema: JsonSchema;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A property's `visibleWhen` as a stable key for deep-equality (absent ⇒ `''`). */
|
|
28
|
+
function visibilityKey(schema: JsonSchema): string {
|
|
29
|
+
return schema.visibleWhen === undefined ? '' : JSON.stringify(schema.visibleWhen);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isDateString(schema: JsonSchema): boolean {
|
|
33
|
+
return schema.format === 'date';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The date-range pairs among `properties` that are drawable as ONE control. A pair
|
|
38
|
+
* qualifies when: the END names an existing sibling START in `rangeStart`; BOTH are
|
|
39
|
+
* `format: date`; both carry the SAME `visibleWhen` (deep-equal, or both absent); AND
|
|
40
|
+
* no OTHER field's `visibleWhen` names either end — otherwise merging the pair would
|
|
41
|
+
* leave that field's predicate pointing at a control that no longer draws on its own,
|
|
42
|
+
* so the pair stays two date fields (the documented degrade). `properties` is ONE
|
|
43
|
+
* page's ordered list; a field is used in at most one pair.
|
|
44
|
+
*/
|
|
45
|
+
export function dateRangePairs(
|
|
46
|
+
properties: readonly (readonly [string, JsonSchema])[],
|
|
47
|
+
): readonly DateRangePair[] {
|
|
48
|
+
const byName = new Map<string, JsonSchema>(properties.map(([name, schema]) => [name, schema]));
|
|
49
|
+
const pairs: DateRangePair[] = [];
|
|
50
|
+
const paired = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const [endName, endSchema] of properties) {
|
|
53
|
+
if (paired.has(endName)) continue;
|
|
54
|
+
const startName = endSchema.rangeStart;
|
|
55
|
+
if (typeof startName !== 'string' || startName === endName || paired.has(startName)) continue;
|
|
56
|
+
const startSchema = byName.get(startName);
|
|
57
|
+
if (startSchema === undefined) continue;
|
|
58
|
+
if (!isDateString(startSchema) || !isDateString(endSchema)) continue;
|
|
59
|
+
if (visibilityKey(startSchema) !== visibilityKey(endSchema)) continue;
|
|
60
|
+
const dependedOn = properties.some(
|
|
61
|
+
([name, schema]) =>
|
|
62
|
+
name !== startName &&
|
|
63
|
+
name !== endName &&
|
|
64
|
+
typeof schema.visibleWhen?.field === 'string' &&
|
|
65
|
+
(schema.visibleWhen.field === startName || schema.visibleWhen.field === endName),
|
|
66
|
+
);
|
|
67
|
+
if (dependedOn) continue;
|
|
68
|
+
pairs.push({ startName, startSchema, endName, endSchema });
|
|
69
|
+
paired.add(startName);
|
|
70
|
+
paired.add(endName);
|
|
71
|
+
}
|
|
72
|
+
return pairs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The field's own `title`, else its property name. */
|
|
76
|
+
function titleOf(schema: JsonSchema, name: string): string {
|
|
77
|
+
return typeof schema.title === 'string' && schema.title.length > 0 ? schema.title : name;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One end of the range: a labelled native date input emitting the date string. */
|
|
81
|
+
function RangeEnd({
|
|
82
|
+
heading,
|
|
83
|
+
schema,
|
|
84
|
+
value,
|
|
85
|
+
required,
|
|
86
|
+
error,
|
|
87
|
+
onChange,
|
|
88
|
+
}: {
|
|
89
|
+
heading: string;
|
|
90
|
+
schema: JsonSchema;
|
|
91
|
+
value: unknown;
|
|
92
|
+
required: boolean;
|
|
93
|
+
error: string | undefined;
|
|
94
|
+
onChange: (value: unknown) => void;
|
|
95
|
+
}): ReactNode {
|
|
96
|
+
const current = typeof value === 'string' ? value : '';
|
|
97
|
+
return (
|
|
98
|
+
<Field label={heading} error={error}>
|
|
99
|
+
<TextInput
|
|
100
|
+
type="date"
|
|
101
|
+
// The native control enforces its inclusive bounds; the validator enforces
|
|
102
|
+
// the rest (unavailable days, the range span) that the control cannot draw.
|
|
103
|
+
min={typeof schema.minDate === 'string' ? schema.minDate : undefined}
|
|
104
|
+
max={typeof schema.maxDate === 'string' ? schema.maxDate : undefined}
|
|
105
|
+
required={required}
|
|
106
|
+
value={current}
|
|
107
|
+
onChange={(event) => {
|
|
108
|
+
const next = event.target.value;
|
|
109
|
+
// An empty optional end drops its key; a required one keeps the empty
|
|
110
|
+
// string so the validator can flag it.
|
|
111
|
+
onChange(next === '' && !required ? undefined : next);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</Field>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function DateRangeField({
|
|
119
|
+
pair,
|
|
120
|
+
startValue,
|
|
121
|
+
endValue,
|
|
122
|
+
startRequired,
|
|
123
|
+
endRequired,
|
|
124
|
+
markRequired,
|
|
125
|
+
startError,
|
|
126
|
+
endError,
|
|
127
|
+
onChangeStart,
|
|
128
|
+
onChangeEnd,
|
|
129
|
+
}: {
|
|
130
|
+
readonly pair: DateRangePair;
|
|
131
|
+
readonly startValue: unknown;
|
|
132
|
+
readonly endValue: unknown;
|
|
133
|
+
readonly startRequired: boolean;
|
|
134
|
+
readonly endRequired: boolean;
|
|
135
|
+
readonly markRequired: boolean;
|
|
136
|
+
readonly startError: string | undefined;
|
|
137
|
+
readonly endError: string | undefined;
|
|
138
|
+
readonly onChangeStart: (value: unknown) => void;
|
|
139
|
+
readonly onChangeEnd: (value: unknown) => void;
|
|
140
|
+
}): ReactNode {
|
|
141
|
+
const startTitle = titleOf(pair.startSchema, pair.startName);
|
|
142
|
+
const endTitle = titleOf(pair.endSchema, pair.endName);
|
|
143
|
+
const mark = (title: string, required: boolean): string =>
|
|
144
|
+
markRequired && required ? `${title} *` : title;
|
|
145
|
+
return (
|
|
146
|
+
<Field label={`${startTitle} – ${endTitle}`} group>
|
|
147
|
+
<div className="tai-row">
|
|
148
|
+
<RangeEnd
|
|
149
|
+
heading={mark(startTitle, startRequired)}
|
|
150
|
+
schema={pair.startSchema}
|
|
151
|
+
value={startValue}
|
|
152
|
+
required={startRequired}
|
|
153
|
+
error={startError}
|
|
154
|
+
onChange={onChangeStart}
|
|
155
|
+
/>
|
|
156
|
+
<RangeEnd
|
|
157
|
+
heading={mark(endTitle, endRequired)}
|
|
158
|
+
schema={pair.endSchema}
|
|
159
|
+
value={endValue}
|
|
160
|
+
required={endRequired}
|
|
161
|
+
error={endError}
|
|
162
|
+
onChange={onChangeEnd}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
</Field>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ReactNode } from 'react';
|
|
8
8
|
|
|
9
|
+
import { ChipGroup } from '../components/chip-group';
|
|
9
10
|
import { Field } from '../components/field';
|
|
10
11
|
import { RadioGroup } from '../components/radio-group';
|
|
11
12
|
import { Select } from '../components/select';
|
|
@@ -22,6 +23,7 @@ export function EnumField({
|
|
|
22
23
|
model,
|
|
23
24
|
value,
|
|
24
25
|
onChange,
|
|
26
|
+
choiceLabel,
|
|
25
27
|
}: {
|
|
26
28
|
heading: string;
|
|
27
29
|
description: string | undefined;
|
|
@@ -29,6 +31,8 @@ export function EnumField({
|
|
|
29
31
|
model: Extract<FieldModel, { kind: 'enum' }>;
|
|
30
32
|
value: unknown;
|
|
31
33
|
onChange: (value: unknown) => void;
|
|
34
|
+
/** The select's placeholder when the field draws as a select; absent shows `Select…`. */
|
|
35
|
+
choiceLabel?: string;
|
|
32
36
|
}): ReactNode {
|
|
33
37
|
const options = model.options.map((option, index) => ({
|
|
34
38
|
key: String(index),
|
|
@@ -44,6 +48,21 @@ export function EnumField({
|
|
|
44
48
|
if (match !== undefined) onChange(match.value);
|
|
45
49
|
};
|
|
46
50
|
|
|
51
|
+
// `control: chips` draws the choice as a chip grid in place of the radio/select,
|
|
52
|
+
// at any option count.
|
|
53
|
+
if (model.control === 'chips') {
|
|
54
|
+
return (
|
|
55
|
+
<Field label={heading} description={description} error={error} group>
|
|
56
|
+
<ChipGroup
|
|
57
|
+
mode="single"
|
|
58
|
+
options={options.map((option) => ({ value: option.key, label: option.label }))}
|
|
59
|
+
value={selectedKey}
|
|
60
|
+
onValueChange={emit}
|
|
61
|
+
/>
|
|
62
|
+
</Field>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
if (options.length <= RADIO_MAX_OPTIONS) {
|
|
48
67
|
return (
|
|
49
68
|
<Field label={heading} description={description} error={error} group>
|
|
@@ -61,6 +80,7 @@ export function EnumField({
|
|
|
61
80
|
options={options.map((option) => ({ value: option.key, label: option.label }))}
|
|
62
81
|
value={selectedKey}
|
|
63
82
|
onValueChange={emit}
|
|
83
|
+
placeholder={choiceLabel}
|
|
64
84
|
/>
|
|
65
85
|
</Field>
|
|
66
86
|
);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* validator import the vocabulary without pulling in classification code.
|
|
6
6
|
*/
|
|
7
7
|
import type { EnumOption } from './resolve';
|
|
8
|
-
import type { JsonSchema } from './types';
|
|
8
|
+
import type { FormControl, FormInputMode, JsonSchema } from './types';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* How a string field accepts binary content as a JSON-serializable string.
|
|
@@ -115,10 +115,28 @@ export interface UnionVariant {
|
|
|
115
115
|
/** The renderer-facing classification of a schema node. */
|
|
116
116
|
export type FieldModel =
|
|
117
117
|
| { readonly kind: 'const'; readonly value: unknown }
|
|
118
|
-
| {
|
|
118
|
+
| {
|
|
119
|
+
readonly kind: 'enum';
|
|
120
|
+
readonly options: readonly EnumOption[];
|
|
121
|
+
/** The `control` keyword off the property: `'chips'` draws the choice as a
|
|
122
|
+
* chip grid in place of the radio/select; `undefined` keeps the default. */
|
|
123
|
+
readonly control: FormControl | undefined;
|
|
124
|
+
}
|
|
119
125
|
| {
|
|
120
126
|
readonly kind: 'string';
|
|
121
127
|
readonly format: string | undefined;
|
|
128
|
+
/** The input kind (keyboard / native control) this string draws, from the
|
|
129
|
+
* schema's `inputMode` keyword; `undefined` for a plain text box. It
|
|
130
|
+
* draws, it does not validate, and it outranks `format` for the control. */
|
|
131
|
+
readonly inputMode: FormInputMode | undefined;
|
|
132
|
+
/** True when the `control` keyword is `'textarea'`: draw a multi-line text
|
|
133
|
+
* control instead of a single-line box. It draws, it does not validate. */
|
|
134
|
+
readonly multiline: boolean;
|
|
135
|
+
/** The `maxLength`/`minLength` JSON-Schema bounds, drawn on the control (a
|
|
136
|
+
* native cap plus a live counter when `maxLength` is set) and enforced by
|
|
137
|
+
* the validator. `undefined` leaves that bound unconstrained. */
|
|
138
|
+
readonly maxLength: number | undefined;
|
|
139
|
+
readonly minLength: number | undefined;
|
|
122
140
|
/** Present when media annotations opt the field into the upload control. */
|
|
123
141
|
readonly media: MediaUpload | undefined;
|
|
124
142
|
/** Present when a well-formed `x-tai42-expression` annotation opts the
|
|
@@ -138,6 +156,10 @@ export type FieldModel =
|
|
|
138
156
|
// `array` field — a multi-select needs a known option set to draw.
|
|
139
157
|
readonly kind: 'multiselect';
|
|
140
158
|
readonly options: readonly EnumOption[];
|
|
159
|
+
/** The `control` keyword off the property: `'chips'` draws the multi-select
|
|
160
|
+
* as a toggle-chip grid in place of the checkbox group; `undefined` keeps
|
|
161
|
+
* the default. */
|
|
162
|
+
readonly control: FormControl | undefined;
|
|
141
163
|
}
|
|
142
164
|
| {
|
|
143
165
|
readonly kind: 'object';
|
|
@@ -41,6 +41,14 @@ interface FieldNodeProps {
|
|
|
41
41
|
readonly markRequired?: boolean;
|
|
42
42
|
readonly errors: SchemaFormErrors | undefined;
|
|
43
43
|
readonly idPrefix: string;
|
|
44
|
+
/**
|
|
45
|
+
* Per-field picker placeholders keyed by property name, handed to an OBJECT node so
|
|
46
|
+
* its direct `enum`-select children draw their placeholder. Carried by the root form
|
|
47
|
+
* only; nested objects receive none.
|
|
48
|
+
*/
|
|
49
|
+
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
50
|
+
/** This field's own picker placeholder, when it draws as an `enum` select. */
|
|
51
|
+
readonly choiceLabel?: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
function labelText(explicit: string | undefined, label: string | undefined): string {
|
|
@@ -73,6 +81,8 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
73
81
|
required,
|
|
74
82
|
errors,
|
|
75
83
|
idPrefix,
|
|
84
|
+
fieldWording: props.fieldWording,
|
|
85
|
+
choiceLabel: props.choiceLabel,
|
|
76
86
|
});
|
|
77
87
|
}
|
|
78
88
|
|
|
@@ -90,6 +100,8 @@ interface RenderFieldArgs {
|
|
|
90
100
|
readonly required: boolean;
|
|
91
101
|
readonly errors: SchemaFormErrors | undefined;
|
|
92
102
|
readonly idPrefix: string;
|
|
103
|
+
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
104
|
+
readonly choiceLabel?: string;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/** Render the field component matching the classified model kind. */
|
|
@@ -126,6 +138,7 @@ function renderField(args: RenderFieldArgs): ReactNode {
|
|
|
126
138
|
model={model}
|
|
127
139
|
value={value}
|
|
128
140
|
onChange={onChange}
|
|
141
|
+
choiceLabel={args.choiceLabel}
|
|
129
142
|
/>
|
|
130
143
|
);
|
|
131
144
|
case 'string':
|
|
@@ -135,6 +148,10 @@ function renderField(args: RenderFieldArgs): ReactNode {
|
|
|
135
148
|
description={description}
|
|
136
149
|
error={error}
|
|
137
150
|
format={model.format}
|
|
151
|
+
inputMode={model.inputMode}
|
|
152
|
+
multiline={model.multiline}
|
|
153
|
+
maxLength={model.maxLength}
|
|
154
|
+
minLength={model.minLength}
|
|
138
155
|
media={model.media}
|
|
139
156
|
expression={model.expression}
|
|
140
157
|
date={model.date}
|
|
@@ -187,6 +204,7 @@ function renderCompositeField(
|
|
|
187
204
|
model: Extract<FieldModel, { kind: 'array' | 'object' | 'record' | 'union' }>,
|
|
188
205
|
): ReactNode {
|
|
189
206
|
const { heading, description, error, root, value, onChange, path, errors, idPrefix } = args;
|
|
207
|
+
const { fieldWording } = args;
|
|
190
208
|
switch (model.kind) {
|
|
191
209
|
case 'array':
|
|
192
210
|
return (
|
|
@@ -216,6 +234,7 @@ function renderCompositeField(
|
|
|
216
234
|
errors={errors}
|
|
217
235
|
idPrefix={idPrefix}
|
|
218
236
|
skip={undefined}
|
|
237
|
+
fieldWording={fieldWording}
|
|
219
238
|
/>
|
|
220
239
|
</FieldGroup>
|
|
221
240
|
);
|