view-contracts 0.5.0 → 0.5.2

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.2
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.0",
3
+ "version": "0.5.2",
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>): 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: e.target.value } } 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: e.target.value } } 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
  }
@@ -275,6 +290,13 @@ function emitVoidInput(
275
290
  if (entry.tag === 'img' && props.alt) attrs.push(`alt={${propExpr(props, 'alt')}}`);
276
291
  if (entry.tag === 'progress' && props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
277
292
  if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}}`);
293
+ const changeHandler = onChangeHandler(props);
294
+ if (changeHandler) {
295
+ if (props.value && entry.tag !== 'progress') attrs.push(`value={${propExpr(props, 'value')}}`);
296
+ attrs.push(changeHandler);
297
+ } else if (props.value && entry.tag !== 'progress') {
298
+ attrs.push(`defaultValue={${propExpr(props, 'value')}}`);
299
+ }
278
300
  return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
279
301
  }
280
302
 
@@ -298,6 +320,15 @@ function emitOpenTag(
298
320
  const handler = actionHandler(props, entry.actionEvent);
299
321
  if (handler) attrs.push(handler);
300
322
  }
323
+ if (entry.tag === 'select' || entry.tag === 'textarea' || entry.tag === 'input') {
324
+ const changeHandler = onChangeHandler(props);
325
+ if (changeHandler) {
326
+ if (props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
327
+ attrs.push(changeHandler);
328
+ } else if (props.value) {
329
+ attrs.push(`defaultValue={${propExpr(props, 'value')}}`);
330
+ }
331
+ }
301
332
  if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
302
333
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
303
334
  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}}`,