@tai42/studio-sdk 21.0.0 → 22.0.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 (62) hide show
  1. package/dist/components/chip-group.d.ts +42 -0
  2. package/dist/components/chip-group.d.ts.map +1 -0
  3. package/dist/components/chip-group.js +85 -0
  4. package/dist/components/chip-group.js.map +1 -0
  5. package/dist/components/components.css +9 -3
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/schema-form/SchemaForm.d.ts +11 -1
  11. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  12. package/dist/schema-form/SchemaForm.js +2 -2
  13. package/dist/schema-form/SchemaForm.js.map +1 -1
  14. package/dist/schema-form/classify.d.ts.map +1 -1
  15. package/dist/schema-form/classify.js +54 -2
  16. package/dist/schema-form/classify.js.map +1 -1
  17. package/dist/schema-form/date-range-field.d.ts +44 -0
  18. package/dist/schema-form/date-range-field.d.ts.map +1 -0
  19. package/dist/schema-form/date-range-field.js +72 -0
  20. package/dist/schema-form/date-range-field.js.map +1 -0
  21. package/dist/schema-form/enum-field.d.ts +3 -1
  22. package/dist/schema-form/enum-field.d.ts.map +1 -1
  23. package/dist/schema-form/enum-field.js +8 -2
  24. package/dist/schema-form/enum-field.js.map +1 -1
  25. package/dist/schema-form/field-model.d.ts +20 -1
  26. package/dist/schema-form/field-model.d.ts.map +1 -1
  27. package/dist/schema-form/field-node.d.ts +10 -0
  28. package/dist/schema-form/field-node.d.ts.map +1 -1
  29. package/dist/schema-form/field-node.js +6 -3
  30. package/dist/schema-form/field-node.js.map +1 -1
  31. package/dist/schema-form/index.d.ts +3 -1
  32. package/dist/schema-form/index.d.ts.map +1 -1
  33. package/dist/schema-form/index.js +1 -0
  34. package/dist/schema-form/index.js.map +1 -1
  35. package/dist/schema-form/multiselect-field.d.ts.map +1 -1
  36. package/dist/schema-form/multiselect-field.js +11 -0
  37. package/dist/schema-form/multiselect-field.js.map +1 -1
  38. package/dist/schema-form/object-fields.d.ts +9 -1
  39. package/dist/schema-form/object-fields.d.ts.map +1 -1
  40. package/dist/schema-form/object-fields.js +16 -3
  41. package/dist/schema-form/object-fields.js.map +1 -1
  42. package/dist/schema-form/string-field.d.ts +6 -1
  43. package/dist/schema-form/string-field.d.ts.map +1 -1
  44. package/dist/schema-form/string-field.js +42 -8
  45. package/dist/schema-form/string-field.js.map +1 -1
  46. package/dist/schema-form/types.d.ts +36 -0
  47. package/dist/schema-form/types.d.ts.map +1 -1
  48. package/package.json +2 -2
  49. package/src/components/chip-group.tsx +157 -0
  50. package/src/components/components.css +9 -3
  51. package/src/index.ts +6 -0
  52. package/src/schema-form/SchemaForm.tsx +10 -0
  53. package/src/schema-form/classify.ts +59 -3
  54. package/src/schema-form/date-range-field.tsx +167 -0
  55. package/src/schema-form/enum-field.tsx +20 -0
  56. package/src/schema-form/field-model.ts +24 -2
  57. package/src/schema-form/field-node.tsx +19 -0
  58. package/src/schema-form/index.ts +10 -1
  59. package/src/schema-form/multiselect-field.tsx +21 -0
  60. package/src/schema-form/object-fields.tsx +36 -2
  61. package/src/schema-form/string-field.tsx +72 -1
  62. package/src/schema-form/types.ts +38 -0
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/schema-form/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/schema-form/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tai42/studio-sdk",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "The Studio plugin contract, shared UI components, and hooks that every feature builds on.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "tai42 <oss@tai42.ai>",
@@ -96,7 +96,7 @@
96
96
  "@radix-ui/react-select": "^2.3.2",
97
97
  "@radix-ui/react-tabs": "^1.1.21",
98
98
  "@radix-ui/react-tooltip": "^1.2.16",
99
- "@tai42/api-client": "21.0.0"
99
+ "@tai42/api-client": "22.0.0"
100
100
  },
101
101
  "scripts": {
102
102
  "build": "tsc -b tsconfig.build.json && node scripts/copy-assets.mjs",
@@ -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(:disabled) {
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
- .tai-chip[aria-pressed='true'] {
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 { model: { kind: 'enum', options }, nullable, schema: resolved, title, description };
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
- | { readonly kind: 'enum'; readonly options: readonly EnumOption[] }
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';