view-contracts 0.5.1 → 0.5.3

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Contract-first UI design toolchain. Compiles restricted view-contract TSX to language-neutral View IR and renders platform targets (React, SwiftUI, Compose).
4
4
 
5
- **Version:** 0.5.0
5
+ **Version:** 0.5.3
6
6
 
7
7
  ## Table of Contents
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -343,6 +343,18 @@ function bindingExpr(binding: IrBinding, ctx: ComposeRenderContext, itemScope?:
343
343
  return binding.path ? `${root}.${binding.path}` : root;
344
344
  }
345
345
 
346
+ function resolveComposeValue(props: Record<string, IrValue>, itemScope?: string): string {
347
+ const valueRaw = props.value;
348
+ if (!valueRaw) return '""';
349
+ if (typeof valueRaw === 'string') return JSON.stringify(valueRaw);
350
+ if (typeof valueRaw === 'object' && valueRaw !== null && 'kind' in valueRaw && valueRaw.kind === 'binding') {
351
+ const binding = valueRaw as { kind: 'binding'; path: string; scope?: string };
352
+ const root = binding.scope ?? (itemScope ?? 'vm');
353
+ return binding.path ? `${root}.${binding.path}` : root;
354
+ }
355
+ return '""';
356
+ }
357
+
346
358
  function wrapWithInheritedStyles(childBlock: string, modifiers: string[], level: number): string {
347
359
  const contentColor = extractContentColor(modifiers);
348
360
  const inheritParts = extractInheritedTextStyleParts(modifiers);
@@ -674,15 +686,16 @@ function renderLeaf(
674
686
  const textStyleLine = textStyleParts.length > 0
675
687
  ? `${pad} textStyle = TextStyle(${textStyleParts.join(', ')}),`
676
688
  : `${pad} textStyle = TextStyle(color = ${textColor}, fontSize = 14.sp),`;
689
+ const composeVal = resolveComposeValue(node.props, itemScope);
677
690
  return [
678
691
  `${pad}BasicTextField(`,
679
- `${pad} value = "",`,
692
+ `${pad} value = ${composeVal},`,
680
693
  `${pad} onValueChange = {},`,
681
694
  `${pad} modifier = ${modifierExpr},`,
682
695
  textStyleLine,
683
696
  `${pad} decorationBox = { innerTextField ->`,
684
697
  `${pad} Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {`,
685
- `${pad} if ("".isEmpty()) {`,
698
+ `${pad} if (${composeVal}.isEmpty()) {`,
686
699
  `${pad} Text(text = ${ph}, style = TextStyle(color = ${mutedColor}, fontSize = 14.sp))`,
687
700
  `${pad} }`,
688
701
  `${pad} innerTextField()`,
@@ -704,15 +717,16 @@ function renderLeaf(
704
717
  const containerExpr = applyModifiers('Modifier', dedupeModifiers(containerMods));
705
718
  const textStyleParts = extractTextStyleModifiers(textMods);
706
719
  const textStyleSuffix = textStyleParts.length > 0 ? `, textStyle = TextStyle(${textStyleParts.join(', ')})` : '';
720
+ const composeVal = resolveComposeValue(node.props, itemScope);
707
721
  return [
708
722
  `${pad}Box(modifier = ${containerExpr}.heightIn(min = ${minHeight}.dp)) {`,
709
723
  `${pad} BasicTextField(`,
710
- `${pad} value = "",`,
724
+ `${pad} value = ${composeVal},`,
711
725
  `${pad} onValueChange = {},`,
712
726
  `${pad} modifier = Modifier.fillMaxWidth()${textStyleSuffix},`,
713
727
  `${pad} decorationBox = { innerTextField ->`,
714
728
  `${pad} Box(modifier = Modifier.fillMaxWidth()) {`,
715
- `${pad} if ("".isEmpty()) {`,
729
+ `${pad} if (${composeVal}.isEmpty()) {`,
716
730
  `${pad} Text(text = ${ph}, modifier = Modifier.align(Alignment.TopStart).padding(top = 8.dp, start = 4.dp), style = TextStyle(color = ${mutedColor}, fontSize = 14.sp))`,
717
731
  `${pad} }`,
718
732
  `${pad} innerTextField()`,
@@ -109,6 +109,21 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
109
109
  return `onClick={() => { void dispatchAction(${expr}); }}`;
110
110
  }
111
111
 
112
+ function onChangeHandler(props: Record<string, IrValue>, targetProp = 'e.target.value'): string | undefined {
113
+ const onChange = props.onChange;
114
+ if (!onChange || typeof onChange !== 'object' || !('kind' in onChange) || onChange.kind !== 'action') {
115
+ return undefined;
116
+ }
117
+ const action = onChange as IrAction;
118
+ if (!action.params || Object.keys(action.params).length === 0) {
119
+ return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { value: ${targetProp} } } as ActionDescriptor); }}`;
120
+ }
121
+ const existingParams = Object.entries(action.params)
122
+ .map(([key, val]) => `${key}: ${renderValue(val, 0)}`)
123
+ .join(', ');
124
+ return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { ${existingParams}, value: ${targetProp} } } as ActionDescriptor); }}`;
125
+ }
126
+
112
127
  function componentToKebab(name: string): string {
113
128
  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
114
129
  }
@@ -182,10 +197,13 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
182
197
  function emitCheckbox(props: Record<string, IrValue>, level: number): string {
183
198
  const name = propExpr(props, 'name') ?? '""';
184
199
  const label = propExpr(props, 'label') ?? '""';
185
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
186
200
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
201
+ const changeAttr = onChangeHandler(props, 'e.target.checked') ?? '';
202
+ const checkedAttr = props.checked
203
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
204
+ : '';
187
205
  return `${indent(level)}<label className="vc-checkbox">
188
- ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
206
+ ${indent(level + 1)}<input type="checkbox" name={${name}} ${checkedAttr} ${disabled} ${changeAttr} />
189
207
  ${indent(level + 1)}<span>{${label}}</span>
190
208
  ${indent(level)}</label>`;
191
209
  }
@@ -194,10 +212,13 @@ function emitRadio(props: Record<string, IrValue>, level: number): string {
194
212
  const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
195
213
  const value = propExpr(props, 'value') ?? '""';
196
214
  const label = propExpr(props, 'label') ?? '""';
197
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
198
215
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
216
+ const changeAttr = onChangeHandler(props, 'e.target.value') ?? '';
217
+ const checkedAttr = props.checked
218
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
219
+ : '';
199
220
  return `${indent(level)}<label className="vc-radio">
200
- ${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checked} ${disabled} />
221
+ ${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checkedAttr} ${disabled} ${changeAttr} />
201
222
  ${indent(level + 1)}<span>{${label}}</span>
202
223
  ${indent(level)}</label>`;
203
224
  }
@@ -205,10 +226,13 @@ ${indent(level)}</label>`;
205
226
  function emitSwitch(props: Record<string, IrValue>, level: number): string {
206
227
  const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
207
228
  const label = propExpr(props, 'label') ?? '""';
208
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
209
229
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
230
+ const changeAttr = onChangeHandler(props, 'e.target.checked') ?? '';
231
+ const checkedAttr = props.checked
232
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
233
+ : '';
210
234
  return `${indent(level)}<label className="vc-switch">
211
- ${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checked} ${disabled} />
235
+ ${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checkedAttr} ${disabled} ${changeAttr} />
212
236
  ${indent(level + 1)}<span>{${label}}</span>
213
237
  ${indent(level)}</label>`;
214
238
  }
@@ -267,7 +291,7 @@ function emitVoidInput(
267
291
  }
268
292
  if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
269
293
  if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
270
- if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
294
+ if (entry.inputType) attrs.push(`type={${propExpr(props, 'inputType') ?? JSON.stringify(entry.inputType)}}`);
271
295
  if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
272
296
  if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
273
297
  if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
@@ -275,6 +299,13 @@ function emitVoidInput(
275
299
  if (entry.tag === 'img' && props.alt) attrs.push(`alt={${propExpr(props, 'alt')}}`);
276
300
  if (entry.tag === 'progress' && props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
277
301
  if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}}`);
302
+ const changeHandler = onChangeHandler(props);
303
+ if (changeHandler) {
304
+ if (props.value && entry.tag !== 'progress') attrs.push(`value={${propExpr(props, 'value')}}`);
305
+ attrs.push(changeHandler);
306
+ } else if (props.value && entry.tag !== 'progress') {
307
+ attrs.push(`defaultValue={${propExpr(props, 'value')}}`);
308
+ }
278
309
  return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
279
310
  }
280
311
 
@@ -298,6 +329,18 @@ function emitOpenTag(
298
329
  const handler = actionHandler(props, entry.actionEvent);
299
330
  if (handler) attrs.push(handler);
300
331
  }
332
+ if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
333
+ if (entry.tag === 'select' || entry.tag === 'textarea' || entry.tag === 'input') {
334
+ if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
335
+ if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
336
+ const changeHandler = onChangeHandler(props);
337
+ if (changeHandler) {
338
+ if (props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
339
+ attrs.push(changeHandler);
340
+ } else if (props.value) {
341
+ attrs.push(`defaultValue={${propExpr(props, 'value')}}`);
342
+ }
343
+ }
301
344
  if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
302
345
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
303
346
  if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
@@ -245,6 +245,25 @@ function bindingExpr(binding: IrBinding, ctx: SwiftUIRenderContext, itemScope?:
245
245
  return binding.path ? `${root}.${binding.path}` : root;
246
246
  }
247
247
 
248
+ function resolveValueBinding(props: Record<string, IrValue>, ctx: SwiftUIRenderContext, itemScope?: string): { valueExpr: string; hasOnChange: boolean } {
249
+ const vmRef = itemScope ?? ctx.propsRoot ?? 'vm';
250
+ const valueRaw = props.value;
251
+ const onChange = props.onChange;
252
+
253
+ const hasOnChange = onChange !== undefined && typeof onChange === 'object' && onChange !== null && 'kind' in onChange && onChange.kind === 'action';
254
+
255
+ let valueExpr: string;
256
+ if (valueRaw && typeof valueRaw === 'object' && 'kind' in valueRaw && valueRaw.kind === 'binding') {
257
+ valueExpr = bindingExpr(valueRaw as IrBinding, ctx, itemScope);
258
+ } else if (typeof valueRaw === 'string') {
259
+ valueExpr = JSON.stringify(valueRaw);
260
+ } else {
261
+ valueExpr = '""';
262
+ }
263
+
264
+ return { valueExpr, hasOnChange };
265
+ }
266
+
248
267
  function renderTextContent(children: IrNode[], ctx: SwiftUIRenderContext, itemScope?: string): string | null {
249
268
  for (const child of children) {
250
269
  if (child.kind === 'text') return JSON.stringify((child as IrTextNode).value);
@@ -309,7 +328,11 @@ function renderLeaf(
309
328
  const placeholder = propLiteral(node.props, 'placeholder');
310
329
  const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
311
330
  const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
312
- const line = `${pad}TextField("", text: .constant(""), prompt: Text(${ph}).foregroundStyle(${mutedColor}))`;
331
+ const { valueExpr, hasOnChange } = resolveValueBinding(node.props, ctx, itemScope);
332
+ const textBinding = hasOnChange
333
+ ? `.init(get: { ${valueExpr} }, set: { _ in })`
334
+ : `.constant(${valueExpr})`;
335
+ const line = `${pad}TextField("", text: ${textBinding}, prompt: Text(${ph}).foregroundStyle(${mutedColor}))`;
313
336
  const allMods = [...modifiers, ...leafExtraModifiers(node.name)];
314
337
  const modLines = allMods.map((mod) => `${pad}${mod}`);
315
338
  return [line, ...modLines].join('\n');
@@ -322,6 +345,11 @@ function renderLeaf(
322
345
  const minHeightMod = modifiers.find((mod) => mod.startsWith('.frame(minHeight:'));
323
346
  const minHeight = minHeightMod?.match(/minHeight: (\d+)/)?.[1] ?? '72';
324
347
 
348
+ const { valueExpr: teValueExpr, hasOnChange: teHasOnChange } = resolveValueBinding(node.props, ctx, itemScope);
349
+ const teTextBinding = teHasOnChange
350
+ ? `.init(get: { ${teValueExpr} }, set: { _ in })`
351
+ : `.constant(${teValueExpr})`;
352
+
325
353
  // Split modifiers: text-related go on TextEditor, visual/layout go on ZStack
326
354
  const textMods = modifiers.filter((mod) =>
327
355
  mod.startsWith('.foregroundStyle(') || mod.startsWith('.font(') || mod.startsWith('.fontWeight(')
@@ -343,7 +371,7 @@ function renderLeaf(
343
371
  `${pad} .padding(.horizontal, 4)`,
344
372
  `${pad} .allowsHitTesting(false)`,
345
373
  `${pad} }`,
346
- `${pad} TextEditor(text: .constant(""))`,
374
+ `${pad} TextEditor(text: ${teTextBinding})`,
347
375
  `${pad} .scrollContentBackground(.hidden)`,
348
376
  ...textModLines,
349
377
  `${pad}}`,