From 6b1499b31fc83be1893e00aa48a23428fbec311b Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 18:47:42 -0700 Subject: [PATCH 01/11] Update text_field.dart --- .../basic_catalog_widgets/text_field.dart | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index cad6e5c0c..fa3ac44c7 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -191,8 +191,8 @@ final textField = CatalogItem( { "id": "root", "component": "TextField", - "value": "Hello World", - "label": "Greeting" + "label": "Enter your name here:" + "textFieldType": "shortText" } ] ''', @@ -201,8 +201,38 @@ final textField = CatalogItem( { "id": "root", "component": "TextField", - "value": "password123", - "label": "Password", + "label": "Type your story here:", + "textFieldType": "longText" + } + ] + ''', + () => ''' + [ + { + "id": "root", + "component": "TextField", + "label": "Type your story here:", + "textFieldType": "longText" + "value": "Once upon a time..." + } + ] + ''', + () => ''' + [ + { + "id": "root", + "component": "TextField", + "label": "What is your initial price?", + "textFieldType": "number", + } + ] + ''', + () => ''' + [ + { + "id": "root", + "component": "TextField", + "label": "Enter your password here", "textFieldType": "obscured" } ] From 4b018e3ced4707d3fe8f7fcbf0303ea5cad2d9ea Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 18:51:02 -0700 Subject: [PATCH 02/11] Update CHANGELOG.md --- packages/genui/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index c45e8e9f3..076a7fa36 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -5,6 +5,8 @@ - Fixed `A2uiTransportAdapter.incomingText` trimming every streamed chunk, which made words run together when chunks were concatenated. +- Added examples of how to use TextField. + ## 0.10.1 - Depend on `a2ui_core` 0.1.0, its first non-pre-release version. From 085aad8dbcb69d9cdb55341829ff8181b26114a8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 18:53:27 -0700 Subject: [PATCH 03/11] Update text_field.dart --- .../lib/src/catalog/basic_catalog_widgets/text_field.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index fa3ac44c7..314174584 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -192,7 +192,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "Enter your name here:" - "textFieldType": "shortText" + "variant": "shortText" } ] ''', @@ -202,7 +202,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "Type your story here:", - "textFieldType": "longText" + "variant": "longText" } ] ''', @@ -212,7 +212,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "Type your story here:", - "textFieldType": "longText" + "variant": "longText" "value": "Once upon a time..." } ] @@ -223,7 +223,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "What is your initial price?", - "textFieldType": "number", + "variant": "number", } ] ''', From e5fb38814e52c0ef2fa17592b08d8aa16e0f8e28 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:04:16 -0700 Subject: [PATCH 04/11] - --- .vscode/launch.json | 6 ++++-- .../lib/src/catalog/basic_catalog_widgets/text_field.dart | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 96900b9c5..5535c97ab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,11 +24,13 @@ "cwd": "examples/verdure/client", "request": "launch", "type": "dart", - "args": ["--dart-define=GEMINI_API_KEY=${env:GEMINI_API_KEY}"] + "args": [ + "--dart-define=GEMINI_API_KEY=${env:GEMINI_API_KEY}" + ] }, { "name": "catalog_gallery", - "cwd": "examples/catalog_gallery", + "cwd": "dev_tools/catalog_gallery", "request": "launch", "type": "dart" }, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index 314174584..e2200457a 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -191,7 +191,7 @@ final textField = CatalogItem( { "id": "root", "component": "TextField", - "label": "Enter your name here:" + "label": "Enter your name here:", "variant": "shortText" } ] @@ -212,7 +212,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "Type your story here:", - "variant": "longText" + "variant": "longText", "value": "Once upon a time..." } ] @@ -223,7 +223,7 @@ final textField = CatalogItem( "id": "root", "component": "TextField", "label": "What is your initial price?", - "variant": "number", + "variant": "number" } ] ''', From 38b954a45c2ba490c05759c006bf9f2438781a97 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:22:08 -0700 Subject: [PATCH 05/11] - --- packages/genui/CHANGELOG.md | 4 + .../basic_catalog_widgets/text_field.dart | 174 ++++++++++++------ .../catalog/core_widgets/text_field_test.dart | 148 +++++++++++++++ 3 files changed, 272 insertions(+), 54 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 076a7fa36..ad20d9070 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -7,6 +7,10 @@ - Added examples of how to use TextField. +- Fixed `TextField` variants: `longText` now grows to fit multiple lines, + `number` now rejects non-numeric input on all platforms, and the `obscured` + example now sets `variant` so that it is actually obscured. + ## 0.10.1 - Depend on `a2ui_core` 0.1.0, its first non-pre-release version. diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index e2200457a..95e74756d 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; @@ -15,19 +16,44 @@ import '../../model/validation_helper.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; +class _Json { + static const String value = 'value'; + static const String label = 'label'; + static const String checks = 'checks'; + static const String variant = 'variant'; + static const String validationRegexp = 'validationRegexp'; + static const String onSubmittedAction = 'onSubmittedAction'; +} + +class _Variant { + static const String shortText = 'shortText'; + static const String longText = 'longText'; + static const String number = 'number'; + static const String obscured = 'obscured'; +} + +final _componentName = 'TextField'; + final _schema = S.object( description: 'A text input field.', properties: { - 'value': A2uiSchemas.stringReference( + _Json.value: A2uiSchemas.stringReference( description: 'The value of the text field.', ), - 'label': A2uiSchemas.stringReference(), - 'variant': S.string( - enumValues: ['shortText', 'longText', 'number', 'obscured'], + _Json.label: A2uiSchemas.stringReference(), + _Json.variant: S.string( + description: + '''The kind of input the field accepts. ${_Variant.shortText} (the default) is a single line of text, ${_Variant.longText} is multi-line text, ${_Variant.number} only accepts numeric input, and ${_Variant.obscured} hides the typed characters, e.g. for passwords.''', + enumValues: [ + _Variant.shortText, + _Variant.longText, + _Variant.number, + _Variant.obscured, + ], ), - 'checks': A2uiSchemas.checkable(), - 'validationRegexp': S.string(), - 'onSubmittedAction': A2uiSchemas.action(), + _Json.checks: A2uiSchemas.checkable(), + _Json.validationRegexp: S.string(), + _Json.onSubmittedAction: A2uiSchemas.action(), }, ); @@ -40,29 +66,43 @@ extension type _TextFieldData.fromMap(JsonMap _json) { String? validationRegexp, JsonMap? onSubmittedAction, }) => _TextFieldData.fromMap({ - 'value': value, - 'label': label, - 'checks': checks, - 'variant': variant, - 'validationRegexp': validationRegexp, - 'onSubmittedAction': onSubmittedAction, + _Json.value: value, + _Json.label: label, + _Json.checks: checks, + _Json.variant: variant, + _Json.validationRegexp: validationRegexp, + _Json.onSubmittedAction: onSubmittedAction, }); - Object? get value => _json['value']; - Object? get label => _json['label']; - List? get checks => (_json['checks'] as List?)?.cast(); - String? get variant => _json['variant'] as String?; - String? get validationRegexp => _json['validationRegexp'] as String?; - JsonMap? get onSubmittedAction => _json['onSubmittedAction'] as JsonMap?; + Object? get value => _json[_Json.value]; + Object? get label => _json[_Json.label]; + List? get checks => (_json[_Json.checks] as List?)?.cast(); + String? get variant => _json[_Json.variant] as String?; + String? get validationRegexp => _json[_Json.validationRegexp] as String?; + JsonMap? get onSubmittedAction => _json[_Json.onSubmittedAction] as JsonMap?; } +/// Matches a number, as well as the partial input it is typed through, such as +/// `-`, `1.` or `-1.5`. +final _numberPattern = RegExp(r'^-?\d*\.?\d*$'); + +/// Rejects any edit that would make the text something other than a number. +/// +/// Partial input such as `-` or `1.` is accepted so that a number can be typed +/// one character at a time; [num.tryParse] is what decides whether the current +/// text is an actual number. +final _numberFormatter = TextInputFormatter.withFunction( + (oldValue, newValue) => + _numberPattern.hasMatch(newValue.text) ? newValue : oldValue, +); + class _TextField extends StatefulWidget { const _TextField({ required this.initialValue, this.label, this.checks, this.context, - this.textFieldType, + this.variant, this.validationRegexp, required this.onChanged, required this.onSubmitted, @@ -72,7 +112,7 @@ class _TextField extends StatefulWidget { final String? label; final List? checks; final DataContext? context; - final String? textFieldType; + final String? variant; final String? validationRegexp; final void Function(String) onChanged; final void Function(String) onSubmitted; @@ -139,18 +179,37 @@ class _TextFieldState extends State<_TextField> { @override Widget build(BuildContext context) { + final String? variant = widget.variant; + final isObscured = variant == _Variant.obscured; + final isLongText = variant == _Variant.longText; + final isNumber = variant == _Variant.number; + return TextField( controller: _controller, decoration: InputDecoration( labelText: widget.label, errorText: _errorText, ), - obscureText: widget.textFieldType == 'obscured', - keyboardType: switch (widget.textFieldType) { - 'number' => .number, - 'longText' => .multiline, + obscureText: isObscured, + // Suggestions and autocorrect would leak or corrupt a password, and are + // meaningless for numbers. + autocorrect: !isObscured && !isNumber, + enableSuggestions: !isObscured && !isNumber, + // `null` lets the field grow with its content; obscured text is only + // valid on a single line. + maxLines: isLongText ? null : 1, + minLines: isLongText ? 3 : null, + keyboardType: switch (variant) { + _Variant.number => const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + _Variant.longText => .multiline, _ => .text, }, + // The keyboard type is only a hint, so numbers are also enforced here, + // which is what stops non-numeric input on desktop and web. + inputFormatters: isNumber ? [_numberFormatter] : null, onChanged: (val) { widget.onChanged(val); // Validation is handled via data model updates + stream @@ -174,66 +233,73 @@ class _TextFieldState extends State<_TextField> { /// /// ## Parameters: /// -/// - `text`: The initial value of the text field. +/// - `value`: The initial value of the text field. /// - `label`: The text to display as the label for the text field. -/// - `textFieldType`: The type of text field. Can be `shortText`, `longText`, -/// `number`, `date`, or `obscured`. +/// - `variant`: The kind of input the field accepts. Can be `shortText` (the +/// default), `longText`, `number`, or `obscured`. +/// - `checks`: Validation checks to run against the field's value. /// - `validationRegexp`: A regular expression to validate the input. /// - `onSubmittedAction`: The action to perform when the user submits the -/// text field. +/// text field. A `longText` field is not submitted by pressing enter, since +/// that inserts a newline instead. final textField = CatalogItem( - name: 'TextField', + name: _componentName, isImplicitlyFlexible: true, dataSchema: _schema, exampleData: [ - () => ''' + () => + ''' [ { "id": "root", - "component": "TextField", - "label": "Enter your name here:", - "variant": "shortText" + "component": "$_componentName", + "${_Json.label}": "Enter your name here:", + "${_Json.variant}": "${_Variant.shortText}" } ] ''', - () => ''' + () => + ''' [ { "id": "root", - "component": "TextField", - "label": "Type your story here:", - "variant": "longText" + "component": "$_componentName", + "${_Json.label}": "Type your story here:", + "${_Json.variant}": "${_Variant.longText}" } ] ''', - () => ''' + () => + ''' [ { "id": "root", - "component": "TextField", - "label": "Type your story here:", - "variant": "longText", - "value": "Once upon a time..." + "component": "$_componentName", + "${_Json.label}": "Type your story here:", + "${_Json.variant}": "${_Variant.longText}", + "${_Json.value}": "Once upon a time..." } ] ''', - () => ''' + () => + ''' [ { "id": "root", - "component": "TextField", - "label": "What is your initial price?", - "variant": "number" + "component": "$_componentName", + "${_Json.label}": "What is your initial price?", + "${_Json.variant}": "${_Variant.number}" } ] ''', - () => ''' + () => + ''' [ { "id": "root", - "component": "TextField", - "label": "Enter your password here", - "textFieldType": "obscured" + "component": "$_componentName", + "${_Json.label}": "Enter your password here", + "${_Json.variant}": "${_Variant.obscured}" } ] ''', @@ -243,7 +309,7 @@ final textField = CatalogItem( final Object? valueRef = textFieldData.value; final path = (valueRef is Map && valueRef.containsKey('path')) ? valueRef['path'] as String - : '${itemContext.id}.value'; + : '${itemContext.id}.${_Json.value}'; return BoundString( dataContext: itemContext.dataContext, value: {'path': path}, @@ -261,10 +327,10 @@ final textField = CatalogItem( label: label, checks: textFieldData.checks, context: itemContext.dataContext, - textFieldType: textFieldData.variant, + variant: textFieldData.variant, validationRegexp: textFieldData.validationRegexp, onChanged: (newValue) { - if (textFieldData.variant == 'number') { + if (textFieldData.variant == _Variant.number) { final num? numberValue = num.tryParse(newValue); if (numberValue != null) { itemContext.dataContext.update(DataPath(path), numberValue); diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 2b30efba1..e4629f167 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -8,6 +8,54 @@ import 'package:genui/genui.dart'; import '../../test_infra/message_builders.dart'; +/// Renders a single `TextField` component bound to `/value`. +Future _pumpVariant( + WidgetTester tester, { + required String variant, +}) async { + final surfaceController = SurfaceController( + catalogs: [BasicCatalogItems.asCatalog()], + ); + addTearDown(surfaceController.dispose); + const surfaceId = 'variantTest'; + + surfaceController.handleMessage( + updateComponents( + surfaceId: surfaceId, + components: [ + component( + id: 'root', + type: 'TextField', + properties: { + 'label': 'Input', + 'variant': variant, + 'value': {'path': '/value'}, + }, + ), + ], + ), + ); + surfaceController.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Surface(surfaceContext: surfaceController.contextFor(surfaceId)), + ), + ), + ); + await tester.pumpAndSettle(); + + return surfaceController; +} + +Object? _value(SurfaceController controller) => controller + .contextFor('variantTest') + .dataModel + .getValue(DataPath('/value')); + void main() { testWidgets('TextField with no weight in Row defaults to weight: 1 ' 'and expands', (WidgetTester tester) async { @@ -298,4 +346,104 @@ void main() { // The text field should convert the integer 123 to "123" expect(find.text('123'), findsOneWidget); }); + + testWidgets('TextField with variant "obscured" hides what is typed', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: 'obscured', + ); + + final TextField field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + // Obscured text is only valid on a single line. + expect(field.maxLines, 1); + // Neither of these should be able to observe a password. + expect(field.autocorrect, isFalse); + expect(field.enableSuggestions, isFalse); + + await tester.enterText(find.byType(TextField), 'hunter2'); + await tester.pumpAndSettle(); + + expect(_value(surfaceController), 'hunter2'); + // The value reaches the data model, but is painted as obscuring characters + // rather than as the typed text. + final EditableText editable = tester.widget(find.byType(EditableText)); + expect(editable.obscureText, isTrue); + }); + + testWidgets('TextField with variant "longText" accepts multiple lines', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: 'longText', + ); + + final TextField field = tester.widget(find.byType(TextField)); + // A null `maxLines` lets the field grow with its content. + expect(field.maxLines, isNull); + expect(field.minLines, 3); + expect(field.keyboardType, TextInputType.multiline); + + final double singleLineHeight = tester + .getSize(find.byType(TextField)) + .height; + + await tester.enterText( + find.byType(TextField), + 'Once upon a time\nthere was a text field\nthat could wrap\nand wrap', + ); + await tester.pumpAndSettle(); + + expect( + _value(surfaceController), + 'Once upon a time\nthere was a text field\nthat could wrap\nand wrap', + ); + // The field grew to fit the extra lines. + expect( + tester.getSize(find.byType(TextField)).height, + greaterThan(singleLineHeight), + ); + }); + + testWidgets('TextField with variant "number" only accepts numbers', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: 'number', + ); + + final TextField field = tester.widget(find.byType(TextField)); + expect(field.maxLines, 1); + expect( + field.keyboardType, + const TextInputType.numberWithOptions(signed: true, decimal: true), + ); + + // Non-numeric input is rejected outright. + await tester.enterText(find.byType(TextField), 'abc'); + await tester.pumpAndSettle(); + expect(find.text('abc'), findsNothing); + expect(_value(surfaceController), isNull); + + // Signed decimals are accepted, and stored as numbers rather than strings. + await tester.enterText(find.byType(TextField), '-12.5'); + await tester.pumpAndSettle(); + expect(find.text('-12.5'), findsOneWidget); + expect(_value(surfaceController), -12.5); + + // A rejected edit leaves the previously entered number untouched. + await tester.enterText(find.byType(TextField), '-12.5e'); + await tester.pumpAndSettle(); + expect(find.text('-12.5'), findsOneWidget); + expect(_value(surfaceController), -12.5); + + // The field can still be cleared. + await tester.enterText(find.byType(TextField), ''); + await tester.pumpAndSettle(); + expect(_value(surfaceController), ''); + }); } From 2398280e78f7a0e55a3c61ec24d7ccf5405adf85 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:26:34 -0700 Subject: [PATCH 06/11] Update text_field_test.dart --- .../catalog/core_widgets/text_field_test.dart | 350 +++++++++++++++++- 1 file changed, 332 insertions(+), 18 deletions(-) diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index e4629f167..6ea12233e 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -2,47 +2,58 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; import '../../test_infra/message_builders.dart'; -/// Renders a single `TextField` component bound to `/value`. -Future _pumpVariant( +const _surfaceId = 'textFieldTest'; +const _catalogId = 'test_catalog'; + +/// Renders a single `TextField` component with the given [properties]. +/// +/// Submitted actions are collected into [submissions], and [functions] are +/// registered on the catalog so that `functionCall` actions can be exercised. +Future _pumpTextField( WidgetTester tester, { - required String variant, + required JsonMap properties, + List? submissions, + List functions = const [], }) async { final surfaceController = SurfaceController( - catalogs: [BasicCatalogItems.asCatalog()], + catalogs: [ + Catalog( + BasicCatalogItems.asCatalog().items, + catalogId: _catalogId, + functions: functions, + ), + ], ); addTearDown(surfaceController.dispose); - const surfaceId = 'variantTest'; + if (submissions != null) { + surfaceController.onSubmit.listen(submissions.add); + } surfaceController.handleMessage( updateComponents( - surfaceId: surfaceId, + surfaceId: _surfaceId, components: [ - component( - id: 'root', - type: 'TextField', - properties: { - 'label': 'Input', - 'variant': variant, - 'value': {'path': '/value'}, - }, - ), + component(id: 'root', type: 'TextField', properties: properties), ], ), ); surfaceController.handleMessage( - createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: _surfaceId, catalogId: _catalogId), ); await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Surface(surfaceContext: surfaceController.contextFor(surfaceId)), + body: Surface(surfaceContext: surfaceController.contextFor(_surfaceId)), ), ), ); @@ -51,11 +62,62 @@ Future _pumpVariant( return surfaceController; } +/// Renders a `TextField` of the given [variant], bound to `/value`. +Future _pumpVariant( + WidgetTester tester, { + String? variant, +}) => _pumpTextField( + tester, + properties: { + 'label': 'Input', + 'variant': ?variant, + 'value': {'path': '/value'}, + }, +); + Object? _value(SurfaceController controller) => controller - .contextFor('variantTest') + .contextFor(_surfaceId) .dataModel .getValue(DataPath('/value')); +/// The `action` payload of a submission, as sent to the agent. +JsonMap _action(ChatMessage message) { + final String interaction = + message.parts.first.asUiInteractionPart!.interaction; + return (jsonDecode(interaction) as JsonMap)['action'] as JsonMap; +} + +/// Submits the focused text field, as pressing enter does. +Future _submit(WidgetTester tester) async { + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); +} + +/// A [ClientFunction] that records the arguments it was called with. +class _RecordingFunction implements ClientFunction { + _RecordingFunction(this.name); + + @override + final String name; + + final List calls = []; + + @override + String get description => 'Records its invocations.'; + + @override + ClientFunctionReturnType get returnType => ClientFunctionReturnType.any; + + @override + Schema get argumentSchema => Schema.object(); + + @override + Stream execute(JsonMap args, ExecutionContext context) { + calls.add(args); + return Stream.value(null); + } +} + void main() { testWidgets('TextField with no weight in Row defaults to weight: 1 ' 'and expands', (WidgetTester tester) async { @@ -446,4 +508,256 @@ void main() { await tester.pumpAndSettle(); expect(_value(surfaceController), ''); }); + + testWidgets('TextField with variant "number" accepts partial input', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: 'number', + ); + + // A number is typed one character at a time, so the intermediate states + // have to survive even though they are not yet parseable numbers. + for (final String partial in ['-', '-1', '-1.', '-1.0']) { + await tester.enterText(find.byType(TextField), partial); + await tester.pumpAndSettle(); + expect(find.text(partial), findsOneWidget, reason: 'typing "$partial"'); + } + + // Anything that does not parse falls back to the raw text, which is what + // keeps the field from fighting the user mid-edit. + await tester.enterText(find.byType(TextField), '-1.'); + await tester.pumpAndSettle(); + expect(_value(surfaceController), '-1.'); + + // A second decimal point is not part of any number, so it is rejected. + await tester.enterText(find.byType(TextField), '-1.2.3'); + await tester.pumpAndSettle(); + expect(find.text('-1.'), findsOneWidget); + }); + + testWidgets('TextField defaults to a single line of plain text', ( + WidgetTester tester, + ) async { + // Both an absent variant and an explicit "shortText" mean the same thing. + for (final String? variant in [null, 'shortText']) { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: variant, + ); + + final TextField field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isFalse, reason: 'variant: $variant'); + expect(field.maxLines, 1, reason: 'variant: $variant'); + expect(field.minLines, isNull, reason: 'variant: $variant'); + expect( + field.keyboardType, + TextInputType.text, + reason: 'variant: $variant', + ); + expect(field.autocorrect, isTrue, reason: 'variant: $variant'); + // Text of any kind is accepted, unlike the number variant. + expect(field.inputFormatters, isNull, reason: 'variant: $variant'); + + await tester.enterText(find.byType(TextField), 'a1! -.'); + await tester.pumpAndSettle(); + expect(_value(surfaceController), 'a1! -.', reason: 'variant: $variant'); + } + }); + + testWidgets('TextField submits its onSubmittedAction with resolved context', ( + WidgetTester tester, + ) async { + final List submissions = []; + await _pumpTextField( + tester, + submissions: submissions, + properties: { + 'label': 'Search', + 'value': {'path': '/value'}, + 'onSubmittedAction': { + 'event': { + 'name': 'search', + 'context': { + 'query': {'path': '/value'}, + }, + }, + }, + }, + ); + + await tester.enterText(find.byType(TextField), 'kittens'); + await tester.pumpAndSettle(); + // Typing alone is not a submission. + expect(submissions, isEmpty); + + await _submit(tester); + + expect(submissions, hasLength(1)); + final JsonMap action = _action(submissions.single); + expect(action['name'], 'search'); + expect(action['sourceComponentId'], 'root'); + // The context is resolved against the data model, not passed through as a + // binding. + expect(action['context'], {'query': 'kittens'}); + }); + + testWidgets('TextField does not submit while a check fails', ( + WidgetTester tester, + ) async { + final List submissions = []; + await _pumpTextField( + tester, + submissions: submissions, + properties: { + 'label': 'Name', + 'value': {'path': '/value'}, + 'checks': [ + { + 'message': 'Must be at least 6 chars', + 'condition': { + 'call': 'length', + 'args': { + 'value': {'path': '/value'}, + 'min': 6, + }, + }, + }, + ], + 'onSubmittedAction': { + 'event': {'name': 'submitted'}, + }, + }, + ); + + await tester.enterText(find.byType(TextField), 'short'); + await tester.pumpAndSettle(); + expect(find.text('Must be at least 6 chars'), findsOneWidget); + + await _submit(tester); + expect(submissions, isEmpty); + + // Once the check passes, the same submission goes through. + await tester.enterText(find.byType(TextField), 'long enough'); + await tester.pumpAndSettle(); + expect(find.text('Must be at least 6 chars'), findsNothing); + + await _submit(tester); + expect(submissions, hasLength(1)); + expect(_action(submissions.single)['name'], 'submitted'); + }); + + testWidgets('TextField runs a functionCall onSubmittedAction', ( + WidgetTester tester, + ) async { + final recorder = _RecordingFunction('recordSubmission'); + await _pumpTextField( + tester, + functions: [recorder], + properties: { + 'label': 'Input', + 'value': {'path': '/value'}, + 'onSubmittedAction': { + 'functionCall': { + 'call': 'recordSubmission', + 'args': { + 'value': {'path': '/value'}, + }, + }, + }, + }, + ); + + await tester.enterText(find.byType(TextField), 'typed'); + await tester.pumpAndSettle(); + expect(recorder.calls, isEmpty); + + await tester.runAsync(() async { + await tester.testTextInput.receiveAction(TextInputAction.done); + }); + await tester.pumpAndSettle(); + + expect(recorder.calls, hasLength(1)); + expect(recorder.calls.single['value'], 'typed'); + }); + + testWidgets('TextField shows a value updated outside of the field', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpVariant(tester); + + await tester.enterText(find.byType(TextField), 'typed by the user'); + await tester.pumpAndSettle(); + expect(find.text('typed by the user'), findsOneWidget); + + // An agent (or any other writer) updates the bound path. + surfaceController.handleMessage( + updateDataModel( + surfaceId: _surfaceId, + path: DataPath('/value'), + value: 'set by the agent', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('set by the agent'), findsOneWidget); + expect(find.text('typed by the user'), findsNothing); + }); + + testWidgets('TextField accepts a literal value and label', ( + WidgetTester tester, + ) async { + await _pumpTextField( + tester, + properties: {'label': 'Your name', 'value': 'Ada Lovelace'}, + ); + + expect(find.text('Ada Lovelace'), findsOneWidget); + expect(find.text('Your name'), findsOneWidget); + }); + + testWidgets('TextField resolves a label bound to the data model', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpTextField( + tester, + properties: { + 'label': {'path': '/label'}, + 'value': {'path': '/value'}, + }, + ); + + surfaceController.handleMessage( + updateDataModel( + surfaceId: _surfaceId, + path: DataPath('/label'), + value: 'Bound label', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Bound label'), findsOneWidget); + }); + + testWidgets('TextField without a value binding writes to its own id', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpTextField( + tester, + properties: {'label': 'Input'}, + ); + + await tester.enterText(find.byType(TextField), 'unbound'); + await tester.pumpAndSettle(); + + // Falls back to ".value" so the text is still readable. + expect( + surfaceController + .contextFor(_surfaceId) + .dataModel + .getValue(DataPath('root.value')), + 'unbound', + ); + }); } From 883bc1e47609e37e40e47acc348aef311ebc5369 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:31:49 -0700 Subject: [PATCH 07/11] - --- packages/genui/CHANGELOG.md | 2 +- .../basic_catalog_widgets/text_field.dart | 17 ++++++- .../catalog/core_widgets/text_field_test.dart | 46 ++++++++++++++----- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index ad20d9070..33eea7440 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -8,7 +8,7 @@ - Added examples of how to use TextField. - Fixed `TextField` variants: `longText` now grows to fit multiple lines, - `number` now rejects non-numeric input on all platforms, and the `obscured` + `number` now rejects non-numeric input, and the `obscured` example now sets `variant` so that it is actually obscured. ## 0.10.1 diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index 95e74756d..425264d14 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -136,7 +136,8 @@ class _TextFieldState extends State<_TextField> { @override void didUpdateWidget(_TextField oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.initialValue != _controller.text) { + if (widget.initialValue != _controller.text && + !_isSameNumberAsTyped(widget.initialValue)) { _controller.text = widget.initialValue; // No need to manually calculate error here, stream should handle it if // related to value. @@ -147,6 +148,18 @@ class _TextFieldState extends State<_TextField> { } } + /// Whether [value] is just another spelling of the number already in the + /// field, such as `-1.0` for a field the user has typed `-1.` into. + /// + /// A number variant writes a [num] to the data model, which comes back as its + /// canonical string. Overwriting the field with that string would rewrite the + /// text mid-edit, so that typing `-1.5` would land on `-1.05`. + bool _isSameNumberAsTyped(String value) { + if (widget.variant != _Variant.number) return false; + final num? parsed = num.tryParse(value); + return parsed != null && parsed == num.tryParse(_controller.text); + } + void _setupValidation() { _validationSubscription?.cancel(); _validationSubscription = null; @@ -287,7 +300,7 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "What is your initial price?", + "${_Json.label}": "What is minimum allowed temperature?", "${_Json.variant}": "${_Variant.number}" } ] diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 6ea12233e..8f906d626 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -26,10 +26,11 @@ Future _pumpTextField( }) async { final surfaceController = SurfaceController( catalogs: [ - Catalog( - BasicCatalogItems.asCatalog().items, + // `copyWith` keeps the basic catalog's own functions, which the `checks` + // in these tests are written against. + BasicCatalogItems.asCatalog().copyWith( catalogId: _catalogId, - functions: functions, + newFunctions: functions, ), ], ); @@ -517,24 +518,47 @@ void main() { variant: 'number', ); - // A number is typed one character at a time, so the intermediate states - // have to survive even though they are not yet parseable numbers. - for (final String partial in ['-', '-1', '-1.', '-1.0']) { + // A number is typed one character at a time, so every intermediate state + // has to survive, including the ones that are not yet numbers. + for (final partial in ['-', '-1', '-1.', '-1.0', '-1.05']) { await tester.enterText(find.byType(TextField), partial); await tester.pumpAndSettle(); expect(find.text(partial), findsOneWidget, reason: 'typing "$partial"'); } - // Anything that does not parse falls back to the raw text, which is what - // keeps the field from fighting the user mid-edit. - await tester.enterText(find.byType(TextField), '-1.'); + // Text that is not yet a number is stored as-is; anything parseable is + // stored as a number. + await tester.enterText(find.byType(TextField), '-'); await tester.pumpAndSettle(); - expect(_value(surfaceController), '-1.'); + expect(_value(surfaceController), '-'); // A second decimal point is not part of any number, so it is rejected. await tester.enterText(find.byType(TextField), '-1.2.3'); await tester.pumpAndSettle(); - expect(find.text('-1.'), findsOneWidget); + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('TextField with variant "number" can be typed into one key at a ' + 'time', (WidgetTester tester) async { + final SurfaceController surfaceController = await _pumpVariant( + tester, + variant: 'number', + ); + + // Typing "12.5" keystroke by keystroke. Writing 12.0 to the data model at + // the "12." keystroke must not rewrite the field, or the next keystroke + // would append to "12.0" and produce 12.05. + for (final String keystroke in '12.5'.split('')) { + final EditableText editable = tester.widget(find.byType(EditableText)); + await tester.enterText( + find.byType(TextField), + '${editable.controller.text}$keystroke', + ); + await tester.pumpAndSettle(); + } + + expect(find.text('12.5'), findsOneWidget); + expect(_value(surfaceController), 12.5); }); testWidgets('TextField defaults to a single line of plain text', ( From 44bc43859bb765638d91acbfc3782ee3c8d34062 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:36:06 -0700 Subject: [PATCH 08/11] Update CHANGELOG.md --- packages/genui/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 33eea7440..787920e68 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,4 +1,4 @@ -# `genui` Changelog +# [genui](https://pub.dev/packages/genui) Changelog ## 0.10.2 From d180f3ef404daab999268c25f112a18557d86354 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:39:05 -0700 Subject: [PATCH 09/11] - --- packages/a2ui_agent/CHANGELOG.md | 2 ++ packages/a2ui_core/CHANGELOG.md | 2 +- packages/genai_primitives/CHANGELOG.md | 2 +- packages/genui_a2a/CHANGELOG.md | 2 +- packages/json_schema_builder/CHANGELOG.md | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/a2ui_agent/CHANGELOG.md b/packages/a2ui_agent/CHANGELOG.md index 0b024e263..1027ad9da 100644 --- a/packages/a2ui_agent/CHANGELOG.md +++ b/packages/a2ui_agent/CHANGELOG.md @@ -1,3 +1,5 @@ +# [a2ui_agent](https://pub.dev/packages/a2ui_agent) Changelog + ## 0.0.1-wip - Initial version. diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 15259e864..3577e4c65 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -1,4 +1,4 @@ -# `a2ui_core` Changelog +# [a2ui_core](https://pub.dev/packages/a2ui_core) Changelog ## 0.1.0 diff --git a/packages/genai_primitives/CHANGELOG.md b/packages/genai_primitives/CHANGELOG.md index 8b3187ad5..757f3f5cb 100644 --- a/packages/genai_primitives/CHANGELOG.md +++ b/packages/genai_primitives/CHANGELOG.md @@ -1,4 +1,4 @@ -# `genai_primitives` Changelog +# [genai_primitives](https://pub.dev/packages/genai_primitives) Changelog ## 0.2.4 diff --git a/packages/genui_a2a/CHANGELOG.md b/packages/genui_a2a/CHANGELOG.md index ddaf883e3..079a77ade 100644 --- a/packages/genui_a2a/CHANGELOG.md +++ b/packages/genui_a2a/CHANGELOG.md @@ -1,4 +1,4 @@ -# `genui_a2a` Changelog +# [genui_a2a](https://pub.dev/packages/genui_a2a) Changelog ## 0.10.1 diff --git a/packages/json_schema_builder/CHANGELOG.md b/packages/json_schema_builder/CHANGELOG.md index be0fc76b7..f799241ed 100644 --- a/packages/json_schema_builder/CHANGELOG.md +++ b/packages/json_schema_builder/CHANGELOG.md @@ -1,4 +1,4 @@ -# `json_schema_builder` Change Log +# [json_schema_builder](https://pub.dev/packages/json_schema_builder) Change Log ## 0.1.6 From 4976d2594df67b1e6b7ae4ba08f6f2ce142ac1e6 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 19:46:40 -0700 Subject: [PATCH 10/11] - --- packages/genui/CHANGELOG.md | 5 + .../basic_catalog_widgets/text_field.dart | 85 ++++++++++-- .../catalog/core_widgets/text_field_test.dart | 123 ++++++++++++++++++ 3 files changed, 200 insertions(+), 13 deletions(-) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 787920e68..d4e5c381c 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -11,6 +11,11 @@ `number` now rejects non-numeric input, and the `obscured` example now sets `variant` so that it is actually obscured. +- `TextField.validationRegexp` is now enforced: the value has to match the + pattern in full, an empty field is exempt, and a value that does not match + shows an error and blocks `onSubmittedAction`. It was previously accepted and + ignored. + ## 0.10.1 - Depend on `a2ui_core` 0.1.0, its first non-pre-release version. diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index 425264d14..5b6553d82 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -13,6 +13,7 @@ import '../../model/catalog_item.dart'; import '../../model/data_model.dart'; import '../../model/ui_models.dart'; import '../../model/validation_helper.dart'; +import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; @@ -52,7 +53,12 @@ final _schema = S.object( ], ), _Json.checks: A2uiSchemas.checkable(), - _Json.validationRegexp: S.string(), + _Json.validationRegexp: S.string( + description: + 'A regular expression the value has to match in full for the field ' + 'to be valid. An empty field is exempt; use a `required` check to ' + 'demand a value at all.', + ), _Json.onSubmittedAction: A2uiSchemas.action(), }, ); @@ -121,15 +127,27 @@ class _TextField extends StatefulWidget { State<_TextField> createState() => _TextFieldState(); } +/// Shown when the text does not match the component's `validationRegexp`, +/// which, unlike a check, carries no message of its own. +const _invalidFormatMessage = 'Invalid format'; + class _TextFieldState extends State<_TextField> { late final TextEditingController _controller; - String? _errorText; + String? _checkError; + String? _formatError; StreamSubscription? _validationSubscription; + /// The error to show, if any. + /// + /// A failing check wins over a failing regexp, since it comes with a message + /// written for this particular field. + String? get _error => _checkError ?? _formatError; + @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initialValue); + _formatError = _formatErrorFor(widget.initialValue); _setupValidation(); } @@ -146,6 +164,28 @@ class _TextFieldState extends State<_TextField> { widget.context != oldWidget.context) { _setupValidation(); } + // A build follows, so the new error does not need a `setState`. + _formatError = _formatErrorFor(_controller.text); + } + + /// The error for [text] not matching [_TextField.validationRegexp], or `null` + /// when it matches. + /// + /// The pattern has to match all of [text], and an empty field is exempt, so + /// that this behaves like the HTML `pattern` attribute that other A2UI + /// renderers map this property to. Demanding a value at all is what a + /// `required` check is for. + String? _formatErrorFor(String text) { + final String? pattern = widget.validationRegexp; + if (pattern == null || text.isEmpty) return null; + final RegExp regexp; + try { + regexp = RegExp('^(?:$pattern)\$'); + } on FormatException catch (error) { + genUiLogger.warning('Invalid validationRegexp "$pattern": $error'); + return null; + } + return regexp.hasMatch(text) ? null : _invalidFormatMessage; } /// Whether [value] is just another spelling of the number already in the @@ -167,8 +207,8 @@ class _TextFieldState extends State<_TextField> { if (widget.checks == null || widget.checks!.isEmpty || widget.context == null) { - if (_errorText != null && mounted) { - setState(() => _errorText = null); + if (_checkError != null && mounted) { + setState(() => _checkError = null); } return; } @@ -177,8 +217,8 @@ class _TextFieldState extends State<_TextField> { ValidationHelper.validateStream(widget.checks, widget.context).listen(( String? newError, ) { - if (newError != _errorText && mounted) { - setState(() => _errorText = newError); + if (newError != _checkError && mounted) { + setState(() => _checkError = newError); } }); } @@ -199,10 +239,7 @@ class _TextFieldState extends State<_TextField> { return TextField( controller: _controller, - decoration: InputDecoration( - labelText: widget.label, - errorText: _errorText, - ), + decoration: InputDecoration(labelText: widget.label, errorText: _error), obscureText: isObscured, // Suggestions and autocorrect would leak or corrupt a password, and are // meaningless for numbers. @@ -224,13 +261,18 @@ class _TextFieldState extends State<_TextField> { // which is what stops non-numeric input on desktop and web. inputFormatters: isNumber ? [_numberFormatter] : null, onChanged: (val) { + // Checks are handled via data model updates + stream, but the regexp + // is checked against the text itself. + final String? formatError = _formatErrorFor(val); + if (formatError != _formatError) { + setState(() => _formatError = formatError); + } widget.onChanged(val); - // Validation is handled via data model updates + stream }, onSubmitted: (val) { // Validation is handled via data model updates + stream // But we check current error state before submitting. - if (_errorText == null) { + if (_error == null) { widget.onSubmitted(val); } }, @@ -251,7 +293,9 @@ class _TextFieldState extends State<_TextField> { /// - `variant`: The kind of input the field accepts. Can be `shortText` (the /// default), `longText`, `number`, or `obscured`. /// - `checks`: Validation checks to run against the field's value. -/// - `validationRegexp`: A regular expression to validate the input. +/// - `validationRegexp`: A regular expression the value has to match in full. +/// An empty field is exempt, matching the HTML `pattern` attribute that other +/// A2UI renderers map this to. /// - `onSubmittedAction`: The action to perform when the user submits the /// text field. A `longText` field is not submitted by pressing enter, since /// that inserts a newline instead. @@ -316,6 +360,21 @@ final textField = CatalogItem( } ] ''', + // A price, written with a dollar sign, greater than zero, and with at most + // two decimals. Character classes keep the pattern free of backslashes, + // which would have to be escaped again to survive JSON. + () => + ''' + [ + { + "id": "root", + "component": "$_componentName", + "${_Json.label}": "What price do you want to offer, e.g. \$9.99?", + "${_Json.variant}": "${_Variant.shortText}", + "${_Json.validationRegexp}": "[\$](?:[1-9][0-9]*(?:[.][0-9]{1,2})?|0[.](?:[1-9][0-9]?|[0-9][1-9]))" + } + ] + ''', ], widgetBuilder: (itemContext) { final textFieldData = _TextFieldData.fromMap(itemContext.data as JsonMap); diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 8f906d626..a1a9042df 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -784,4 +784,127 @@ void main() { 'unbound', ); }); + + testWidgets('TextField reports a value that fails its validationRegexp', ( + WidgetTester tester, + ) async { + final List submissions = []; + await _pumpTextField( + tester, + submissions: submissions, + properties: { + 'label': 'Zip code', + 'value': {'path': '/value'}, + 'validationRegexp': '[0-9]{5}', + 'onSubmittedAction': { + 'event': {'name': 'submitted'}, + }, + }, + ); + + // An empty field is exempt, so the form does not open covered in errors. + expect(find.text('Invalid format'), findsNothing); + + await tester.enterText(find.byType(TextField), '123'); + await tester.pumpAndSettle(); + expect(find.text('Invalid format'), findsOneWidget); + + // The regexp has to match the whole value, so a longer zip code that + // merely contains five digits is still invalid. + await tester.enterText(find.byType(TextField), '123456'); + await tester.pumpAndSettle(); + expect(find.text('Invalid format'), findsOneWidget); + + // An invalid value cannot be submitted, just like a failing check. + await _submit(tester); + expect(submissions, isEmpty); + + await tester.enterText(find.byType(TextField), '12345'); + await tester.pumpAndSettle(); + expect(find.text('Invalid format'), findsNothing); + + await _submit(tester); + expect(submissions, hasLength(1)); + }); + + testWidgets('TextField reports a value bound in from the data model that ' + 'fails its validationRegexp', (WidgetTester tester) async { + final SurfaceController surfaceController = await _pumpTextField( + tester, + properties: { + 'label': 'Zip code', + 'value': {'path': '/value'}, + 'validationRegexp': '[0-9]{5}', + }, + ); + + surfaceController.handleMessage( + updateDataModel( + surfaceId: _surfaceId, + path: DataPath('/value'), + value: 'not a zip', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Invalid format'), findsOneWidget); + }); + + testWidgets('TextField ignores a validationRegexp that does not compile', ( + WidgetTester tester, + ) async { + final SurfaceController surfaceController = await _pumpTextField( + tester, + properties: { + 'label': 'Input', + 'value': {'path': '/value'}, + 'validationRegexp': '[unterminated', + }, + ); + + await tester.enterText(find.byType(TextField), 'anything'); + await tester.pumpAndSettle(); + + // The field stays usable rather than rejecting everything the user types. + expect(find.text('Invalid format'), findsNothing); + expect(_value(surfaceController), 'anything'); + }); + + testWidgets('TextField validates a price with the catalog example regexp', ( + WidgetTester tester, + ) async { + // Taken from the shipped example rather than copied, so that the example + // and what it promises cannot drift apart. + final JsonMap example = BasicCatalogItems.textField.exampleData + .map((build) => (jsonDecode(build()) as List).first as JsonMap) + .firstWhere((component) => component.containsKey('validationRegexp')); + + await _pumpTextField( + tester, + properties: { + 'label': example['label'], + 'value': {'path': '/value'}, + 'validationRegexp': example['validationRegexp'], + }, + ); + + for (final valid in [r'$5', r'$12.99', r'$0.99', r'$0.01', r'$10.5']) { + await tester.enterText(find.byType(TextField), valid); + await tester.pumpAndSettle(); + expect(find.text('Invalid format'), findsNothing, reason: valid); + } + + for (final invalid in [ + '5', // No dollar sign. + r'-$5', // Not positive. + r'$0', // Not positive. + r'$0.00', // Not positive. + r'$12.345', // More than two decimals. + r'$12.99 or best offer', // Matches only part of the value. + ]) { + await tester.enterText(find.byType(TextField), invalid); + await tester.pumpAndSettle(); + expect(find.text('Invalid format'), findsOneWidget, reason: invalid); + } + }); } From 3410199fe2fee11a50126172171700e85d91baf5 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 20:05:26 -0700 Subject: [PATCH 11/11] Update text_field.dart --- .../basic_catalog_widgets/text_field.dart | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index 5b6553d82..ca2744ccb 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -17,7 +17,7 @@ import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; -class _Json { +class _Fields { static const String value = 'value'; static const String label = 'label'; static const String checks = 'checks'; @@ -38,11 +38,11 @@ final _componentName = 'TextField'; final _schema = S.object( description: 'A text input field.', properties: { - _Json.value: A2uiSchemas.stringReference( + _Fields.value: A2uiSchemas.stringReference( description: 'The value of the text field.', ), - _Json.label: A2uiSchemas.stringReference(), - _Json.variant: S.string( + _Fields.label: A2uiSchemas.stringReference(), + _Fields.variant: S.string( description: '''The kind of input the field accepts. ${_Variant.shortText} (the default) is a single line of text, ${_Variant.longText} is multi-line text, ${_Variant.number} only accepts numeric input, and ${_Variant.obscured} hides the typed characters, e.g. for passwords.''', enumValues: [ @@ -52,14 +52,14 @@ final _schema = S.object( _Variant.obscured, ], ), - _Json.checks: A2uiSchemas.checkable(), - _Json.validationRegexp: S.string( + _Fields.checks: A2uiSchemas.checkable(), + _Fields.validationRegexp: S.string( description: 'A regular expression the value has to match in full for the field ' 'to be valid. An empty field is exempt; use a `required` check to ' 'demand a value at all.', ), - _Json.onSubmittedAction: A2uiSchemas.action(), + _Fields.onSubmittedAction: A2uiSchemas.action(), }, ); @@ -72,20 +72,22 @@ extension type _TextFieldData.fromMap(JsonMap _json) { String? validationRegexp, JsonMap? onSubmittedAction, }) => _TextFieldData.fromMap({ - _Json.value: value, - _Json.label: label, - _Json.checks: checks, - _Json.variant: variant, - _Json.validationRegexp: validationRegexp, - _Json.onSubmittedAction: onSubmittedAction, + _Fields.value: value, + _Fields.label: label, + _Fields.checks: checks, + _Fields.variant: variant, + _Fields.validationRegexp: validationRegexp, + _Fields.onSubmittedAction: onSubmittedAction, }); - Object? get value => _json[_Json.value]; - Object? get label => _json[_Json.label]; - List? get checks => (_json[_Json.checks] as List?)?.cast(); - String? get variant => _json[_Json.variant] as String?; - String? get validationRegexp => _json[_Json.validationRegexp] as String?; - JsonMap? get onSubmittedAction => _json[_Json.onSubmittedAction] as JsonMap?; + Object? get value => _json[_Fields.value]; + Object? get label => _json[_Fields.label]; + List? get checks => + (_json[_Fields.checks] as List?)?.cast(); + String? get variant => _json[_Fields.variant] as String?; + String? get validationRegexp => _json[_Fields.validationRegexp] as String?; + JsonMap? get onSubmittedAction => + _json[_Fields.onSubmittedAction] as JsonMap?; } /// Matches a number, as well as the partial input it is typed through, such as @@ -310,8 +312,8 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "Enter your name here:", - "${_Json.variant}": "${_Variant.shortText}" + "${_Fields.label}": "Enter your name here:", + "${_Fields.variant}": "${_Variant.shortText}" } ] ''', @@ -321,8 +323,8 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "Type your story here:", - "${_Json.variant}": "${_Variant.longText}" + "${_Fields.label}": "Type your story here:", + "${_Fields.variant}": "${_Variant.longText}" } ] ''', @@ -332,9 +334,9 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "Type your story here:", - "${_Json.variant}": "${_Variant.longText}", - "${_Json.value}": "Once upon a time..." + "${_Fields.label}": "Type your story here:", + "${_Fields.variant}": "${_Variant.longText}", + "${_Fields.value}": "Once upon a time..." } ] ''', @@ -344,8 +346,8 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "What is minimum allowed temperature?", - "${_Json.variant}": "${_Variant.number}" + "${_Fields.label}": "What is minimum allowed temperature?", + "${_Fields.variant}": "${_Variant.number}" } ] ''', @@ -355,8 +357,8 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "Enter your password here", - "${_Json.variant}": "${_Variant.obscured}" + "${_Fields.label}": "Enter your password here", + "${_Fields.variant}": "${_Variant.obscured}" } ] ''', @@ -369,9 +371,9 @@ final textField = CatalogItem( { "id": "root", "component": "$_componentName", - "${_Json.label}": "What price do you want to offer, e.g. \$9.99?", - "${_Json.variant}": "${_Variant.shortText}", - "${_Json.validationRegexp}": "[\$](?:[1-9][0-9]*(?:[.][0-9]{1,2})?|0[.](?:[1-9][0-9]?|[0-9][1-9]))" + "${_Fields.label}": "What price do you want to offer, e.g. \$9.99?", + "${_Fields.variant}": "${_Variant.shortText}", + "${_Fields.validationRegexp}": "[\$](?:[1-9][0-9]*(?:[.][0-9]{1,2})?|0[.](?:[1-9][0-9]?|[0-9][1-9]))" } ] ''', @@ -381,7 +383,7 @@ final textField = CatalogItem( final Object? valueRef = textFieldData.value; final path = (valueRef is Map && valueRef.containsKey('path')) ? valueRef['path'] as String - : '${itemContext.id}.${_Json.value}'; + : '${itemContext.id}.${_Fields.value}'; return BoundString( dataContext: itemContext.dataContext, value: {'path': path},