Skip to content

Commit 4dde0fa

Browse files
committed
Text fields: error, focus and autofocus follow variables
An error text that resolves to empty (or null) is no error, so a field's error state can follow a variable; a bound errorText variable takes precedence over the design one. A field's focused variable is written with its focus, so other nodes can style by it (a focus ring). autoFocus can be bound (applies when the field is first built). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012anUJ6o6K6kjUXur8Ak8XX
1 parent c5f74bd commit 4dde0fa

2 files changed

Lines changed: 220 additions & 12 deletions

File tree

‎lib/src/transformers/node_transformers/passive_text_field_transformer.dart‎

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:codelessly_api/codelessly_api.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
4+
import 'package:provider/provider.dart';
45

56
import '../../../codelessly_sdk.dart';
67
import '../utils/node_state_provider.dart';
@@ -271,6 +272,11 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
271272
}
272273

273274
void onFocusChanged() {
275+
// A `focused` variable follows the field's focus, so other nodes can
276+
// style themselves by it (a focus ring drawn around the field).
277+
if (mounted && widget.node.variables['focused'] != null) {
278+
_setFocusedVariable(context, widget.node, focusNode.hasFocus);
279+
}
274280
if (!focusNode.hasFocus) {
275281
widget.onSubmitted?.call(context, controller.text);
276282
}
@@ -306,9 +312,19 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
306312
final FormFieldValidator<String> validator =
307313
widget.validator ?? validatorModel.validate;
308314

315+
// A bound `autoFocus` (e.g. only for a new item on desktop) replaces the
316+
// design value. Like Flutter's, it applies when the field is first built.
309317
final bool autofocus =
310318
!widget.settings.isPreview &&
311-
(widget.autofocus ?? properties.autoFocus);
319+
(widget.autofocus ??
320+
(node.variables['autoFocus'] == null
321+
? null
322+
: PropertyValueDelegate.getPropertyValue<bool>(
323+
node,
324+
'autoFocus',
325+
scopedValues: scopedValues,
326+
)) ??
327+
properties.autoFocus);
312328

313329
// Flutter can only obscure a single-line field, and a field that expands
314330
// has no line limit, so an obscured field (a password) doesn't expand:
@@ -451,21 +467,32 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
451467
scopedValues: scopedValues,
452468
settings: settings,
453469
);
454-
final Widget? errorText = buildText(
455-
context,
470+
// The field is in error only while its error text has something to
471+
// show: a template or bound value that resolves to empty (or null) means
472+
// no error, so the error state can follow a variable.
473+
final String? error = resolveErrorText(
456474
node,
457-
property: 'errorText',
458-
defaultValue: decoration.errorText,
475+
decoration.errorText,
459476
scopedValues: scopedValues,
460477
settings: settings,
461-
variablesOverrides: widget.variablesOverrides,
462-
color: decoration.errorStyle.fills.first.toFlutterColor(),
463-
fontSize: decoration.errorStyle.fontSize,
464-
letterSpacing: decoration.errorStyle.letterSpacing,
465-
lineHeight: decoration.errorStyle.lineHeight,
466-
fontName: decoration.errorStyle.fontName,
467-
textDecoration: decoration.errorStyle.textDecoration,
468478
);
479+
final Widget? errorText = error == null
480+
? null
481+
: buildText(
482+
context,
483+
node,
484+
property: 'errorText',
485+
defaultValue: error,
486+
scopedValues: scopedValues,
487+
settings: settings,
488+
variablesOverrides: widget.variablesOverrides,
489+
color: decoration.errorStyle.fills.first.toFlutterColor(),
490+
fontSize: decoration.errorStyle.fontSize,
491+
letterSpacing: decoration.errorStyle.letterSpacing,
492+
lineHeight: decoration.errorStyle.lineHeight,
493+
fontName: decoration.errorStyle.fontName,
494+
textDecoration: decoration.errorStyle.textDecoration,
495+
);
469496
final Widget? prefixText = buildText(
470497
context,
471498
node,
@@ -594,6 +621,34 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
594621
);
595622
}
596623

624+
/// The error text [node] shows, or null when it has none: a bound
625+
/// `errorText` variable (or a condition or set value action) takes
626+
/// precedence over the design [errorText], and a value that substitutes
627+
/// to an empty string, or a null value, means no error.
628+
String? resolveErrorText(
629+
TextFieldNode node,
630+
String? errorText, {
631+
required ScopedValues scopedValues,
632+
required WidgetBuildSettings settings,
633+
}) {
634+
final bool bound = node.variables['errorText'] != null;
635+
final Object? value = bound
636+
? PropertyValueDelegate.getPropertyValue<Object>(
637+
node,
638+
'errorText',
639+
scopedValues: scopedValues,
640+
)
641+
: context.getNodeValue(node.id, 'errorText') ?? errorText;
642+
if (value == null) return null;
643+
final String text = PropertyValueDelegate.substituteVariables(
644+
value.toString(),
645+
scopedValues: scopedValues,
646+
nullSubstitutionMode: NullSubstitutionMode.emptyString,
647+
);
648+
if (text.trim().isEmpty || text == 'null') return null;
649+
return text;
650+
}
651+
597652
InputBorder? getInputBorder(InputBorderModel? inputBorder) {
598653
if (inputBorder == null) return null;
599654
switch (inputBorder.borderType) {
@@ -674,6 +729,21 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
674729
}
675730
}
676731

732+
/// Writes [focused] to the variable [node] binds its `focused` property to.
733+
void _setFocusedVariable(BuildContext context, BaseNode node, bool focused) {
734+
final String? path = node.variables['focused'];
735+
if (path == null) return;
736+
final VariableMatch? match = VariableMatch.parse(
737+
path.wrapWithVariableSyntax(),
738+
);
739+
if (match == null || match.hasPath) return;
740+
final ValueNotifier<VariableData>? notifier = context
741+
.read<CodelesslyContext>()
742+
.findVariableByName(match.name);
743+
if (notifier == null || notifier.value.value == '$focused') return;
744+
notifier.value = notifier.value.copyWith(value: focused);
745+
}
746+
677747
class _ReactiveIcon extends StatelessWidget {
678748
final VoidCallback onTap;
679749
final ReactiveIconModel iconModel;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import 'package:codelessly_api/codelessly_api.dart';
2+
import 'package:codelessly_sdk/codelessly_sdk.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:provider/provider.dart';
6+
7+
/// A form field whose error state, focus and autofocus follow variables.
8+
void main() {
9+
Observable<VariableData> variable(String name, VariableType type, Object v) =>
10+
Observable(VariableData(id: name, name: name, type: type, value: v));
11+
12+
TextFieldNode field({
13+
String? errorText,
14+
Map<String, String> variables = const {},
15+
}) => TextFieldNode.fromJson({
16+
...TextFieldNode(
17+
id: 'field',
18+
name: 'Field',
19+
basicBoxLocal: NodeBox(0, 0, 300, 48),
20+
properties: TextFieldProperties(
21+
decoration: InputDecorationModel(errorText: errorText),
22+
),
23+
).toJson(),
24+
'variables': variables,
25+
});
26+
27+
Future<CodelesslyContext> pump(
28+
WidgetTester tester,
29+
TextFieldNode node,
30+
List<Observable<VariableData>> variables,
31+
) async {
32+
final registry = NodeRegistry()..setNodes({node.id: node});
33+
final manager = PassiveNodeTransformerManager(
34+
registry.getNodeByID,
35+
(context, bounds, pageID, layoutID, canvasID) => const SizedBox(),
36+
);
37+
final codelesslyContext = CodelesslyContext.empty();
38+
for (final v in variables) {
39+
codelesslyContext.variables[v.value.id] = v;
40+
}
41+
await tester.pumpWidget(
42+
ChangeNotifierProvider<CodelesslyContext>.value(
43+
value: codelesslyContext,
44+
child: MaterialApp(
45+
home: Scaffold(
46+
body: Center(
47+
child: Builder(
48+
builder: (context) => manager.buildWidgetFromNode(
49+
node,
50+
context,
51+
settings: const WidgetBuildSettings(debugLabel: 'test'),
52+
),
53+
),
54+
),
55+
),
56+
),
57+
),
58+
);
59+
await tester.pump();
60+
return codelesslyContext;
61+
}
62+
63+
InputDecoration decoration(WidgetTester tester) =>
64+
tester.widget<InputDecorator>(find.byType(InputDecorator)).decoration;
65+
66+
testWidgets('an error text template that resolves empty is no error', (
67+
tester,
68+
) async {
69+
final noteError = variable('noteError', VariableType.text, '');
70+
await pump(tester, field(errorText: r'${noteError}'), [noteError]);
71+
expect(tester.takeException(), isNull);
72+
expect(decoration(tester).error, isNull);
73+
74+
noteError.set(noteError.value.copyWith(value: 'Add a note'));
75+
await tester.pump();
76+
expect(decoration(tester).error, isNotNull);
77+
expect(find.text('Add a note'), findsOneWidget);
78+
79+
noteError.set(noteError.value.copyWith(value: ''));
80+
await tester.pump();
81+
expect(decoration(tester).error, isNull);
82+
expect(find.text('Add a note'), findsNothing);
83+
});
84+
85+
testWidgets('a bound errorText follows a map field, null is no error', (
86+
tester,
87+
) async {
88+
final formErrors = variable('formErrors', VariableType.map, {
89+
'title': null,
90+
});
91+
await pump(
92+
tester,
93+
field(variables: {'errorText': r'${formErrors.title}'}),
94+
[formErrors],
95+
);
96+
expect(decoration(tester).error, isNull);
97+
98+
formErrors.set(
99+
formErrors.value.copyWith(value: {'title': 'Title is required'}),
100+
);
101+
await tester.pump();
102+
expect(find.text('Title is required'), findsOneWidget);
103+
});
104+
105+
testWidgets('a static error text is still an error', (tester) async {
106+
await pump(tester, field(errorText: 'Wrong'), []);
107+
expect(find.text('Wrong'), findsOneWidget);
108+
});
109+
110+
testWidgets('a focused variable follows the field focus', (tester) async {
111+
final focused = variable('titleFocused', VariableType.boolean, false);
112+
await pump(tester, field(variables: {'focused': 'titleFocused'}), [
113+
focused,
114+
]);
115+
await tester.tap(find.byType(TextField));
116+
await tester.pump();
117+
expect(focused.value.value, 'true');
118+
119+
FocusManager.instance.primaryFocus?.unfocus();
120+
await tester.pump();
121+
expect(focused.value.value, 'false');
122+
expect(tester.takeException(), isNull);
123+
});
124+
125+
for (final bool value in [true, false]) {
126+
testWidgets('a bound autoFocus of $value', (tester) async {
127+
final editing = variable('editing', VariableType.boolean, !value);
128+
await pump(tester, field(variables: {'autoFocus': r'${= !editing}'}), [
129+
editing,
130+
]);
131+
expect(tester.widget<TextField>(find.byType(TextField)).autofocus, value);
132+
final EditableTextState editable = tester.state(
133+
find.byType(EditableText),
134+
);
135+
expect(editable.widget.focusNode.hasFocus, value);
136+
});
137+
}
138+
}

0 commit comments

Comments
 (0)