diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb1e325..c489c255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +### Layout + +- Row/column `spacing`: a gap between children along the main axis, taken + out before expanded children share the free space. +- Row/column `wrap` and `runSpacing`: children wrap onto further runs, like + Flutter's `Wrap`. + +### Variables + +- A variable whose value is a `${= ...}` formula keeps it for every type + (computed variables). + ## 6.1.0 ### States and conditions diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index b182ef92..7c00015e 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -1109,6 +1109,46 @@ mixin RowColumnMixin on BaseNode { /// for details on available alignments. late CrossAxisAlignmentC crossAxisAlignment; + /// The gap between consecutive children along the main axis, in logical + /// pixels. Corresponds to the `spacing` property of [Row] and [Column] in + /// Flutter and to item spacing in Figma auto-layout. + /// + /// Unlike margins, the spacing is subtracted from the main axis extent + /// before free space is distributed to flexible children, so expanded + /// children with equal flex receive equal sizes. + @JsonKey(defaultValue: 0.0) + double spacing = 0; + + /// Whether the children wrap onto further runs (lines) when they do not + /// fit along the main axis, like Flutter's [Wrap] and Figma auto-layout + /// wrap. A wrapping row lays its children out like + /// `Wrap(direction: Axis.horizontal)` and a wrapping column like + /// `Wrap(direction: Axis.vertical)`. + /// + /// While wrapping: + /// - [spacing] is the gap between consecutive children within a run and + /// [runSpacing] the gap between runs. Children hidden by a visibility + /// formula take no space and no gaps. + /// - [mainAxisAlignment] aligns the children within each run (Flutter's + /// `Wrap.alignment`). + /// - [crossAxisAlignment] aligns the children within their run + /// (`Wrap.crossAxisAlignment`; stretch and baseline act as start) and + /// also aligns the runs in the cross-axis free space + /// (`Wrap.runAlignment`: start, center or end), as in Figma. + /// - Children cannot be flexible: an expanded (or flex) child is laid out + /// at its own size, as a fixed child, like inside a Flutter [Wrap]. + /// A child that fills the main axis takes a whole run. + /// - A shrink-wrapping cross axis hugs the runs; a shrink-wrapping main + /// axis hugs the widest run. + @JsonKey(defaultValue: false) + bool wrap = false; + + /// The gap between consecutive runs along the cross axis, in logical + /// pixels, when [wrap] is on. Corresponds to `Wrap.runSpacing` in + /// Flutter. Has no effect when [wrap] is off. + @JsonKey(defaultValue: 0.0) + double runSpacing = 0; + /// Returns the main axis based on whether this is a row or column. AxisC get mainAxis => (rowColumnType == RowColumnType.row) ? AxisC.horizontal : AxisC.vertical; @@ -1122,10 +1162,16 @@ mixin RowColumnMixin on BaseNode { required RowColumnType rowColumnType, required MainAxisAlignmentC mainAxisAlignment, required CrossAxisAlignmentC crossAxisAlignment, + double spacing = 0, + bool wrap = false, + double runSpacing = 0, }) { this.rowColumnType = rowColumnType; this.mainAxisAlignment = mainAxisAlignment; this.crossAxisAlignment = crossAxisAlignment; + this.spacing = spacing; + this.wrap = wrap; + this.runSpacing = runSpacing; } @override diff --git a/lib/src/api/models/variables_model.dart b/lib/src/api/models/variables_model.dart index eb860565..7e703dcd 100644 --- a/lib/src/api/models/variables_model.dart +++ b/lib/src/api/models/variables_model.dart @@ -228,8 +228,12 @@ class VariableData String? sanitizeValueForVariableType(Object? value, VariableType type) { if (value == null) return null; // if (value.isEmpty) return ''; - // Do not sanitize if the value contains a variable path. - if (value.toString().contains(variablePathPattern)) return value.toString(); + // Do not sanitize if the value contains a variable path or a formula: a + // computed variable's value is a `${= ...}` formula whatever its type. + if (value.toString().contains(variablePathPattern) || + value.toString().contains(r'${=')) { + return value.toString(); + } switch (type) { case VariableType.text: case VariableType.image: diff --git a/lib/src/api/nodes/accordion_node.g.dart b/lib/src/api/nodes/accordion_node.g.dart index 77f4f620..f2bc76e5 100644 --- a/lib/src/api/nodes/accordion_node.g.dart +++ b/lib/src/api/nodes/accordion_node.g.dart @@ -52,6 +52,9 @@ Map _$AccordionNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -179,6 +182,9 @@ AccordionNode _$AccordionNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AccordionNodeToJson( @@ -219,6 +225,9 @@ Map _$AccordionNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'properties': instance.properties.toJson(), }; diff --git a/lib/src/api/nodes/auto_placeholder_node.g.dart b/lib/src/api/nodes/auto_placeholder_node.g.dart index 29e36b8f..68fbcbd2 100644 --- a/lib/src/api/nodes/auto_placeholder_node.g.dart +++ b/lib/src/api/nodes/auto_placeholder_node.g.dart @@ -70,6 +70,9 @@ Map _$AutoPlaceholderNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -236,6 +239,9 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AutoPlaceholderNodeToJson( @@ -289,6 +295,9 @@ Map _$AutoPlaceholderNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'ephemeral': instance.ephemeral, }; diff --git a/lib/src/api/nodes/canvas_node.g.dart b/lib/src/api/nodes/canvas_node.g.dart index 70aeb784..5f4c9a09 100644 --- a/lib/src/api/nodes/canvas_node.g.dart +++ b/lib/src/api/nodes/canvas_node.g.dart @@ -71,6 +71,9 @@ Map _$CanvasNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); @@ -290,6 +293,9 @@ CanvasNode _$CanvasNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$CanvasNodeToJson( @@ -344,6 +350,9 @@ Map _$CanvasNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'isScrollable': instance.isScrollable, 'scrollDirection': _$AxisCEnumMap[instance.scrollDirection]!, 'reverse': instance.reverse, diff --git a/lib/src/api/nodes/expansion_tile_node.g.dart b/lib/src/api/nodes/expansion_tile_node.g.dart index 50dde0fe..e2a4ca38 100644 --- a/lib/src/api/nodes/expansion_tile_node.g.dart +++ b/lib/src/api/nodes/expansion_tile_node.g.dart @@ -46,6 +46,9 @@ Map _$ExpansionTileNodeOmitDefaults( json.remove('componentSchema'); if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -205,6 +208,9 @@ ExpansionTileNode _$ExpansionTileNodeFromJson(Map json) => _$CrossAxisAlignmentCEnumMap, json['crossAxisAlignment'], ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$ExpansionTileNodeToJson( @@ -245,6 +251,9 @@ Map _$ExpansionTileNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'isExpanded': instance.isExpanded, 'listTileChild': ?instance.listTileChild, diff --git a/lib/src/api/nodes/row_column_node.dart b/lib/src/api/nodes/row_column_node.dart index 2094d2f7..c7d527df 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../field_access.dart'; import '../mixins.dart'; import '../models/models.dart'; @@ -16,10 +17,14 @@ class RowColumnNode extends ParentNode @override final bool supportsPadding = true; + /// A row/column scrolls along its main axis. One that [wrap]s scrolls + /// along its cross axis instead, through its runs: its main axis is where + /// it wraps, so it needs a bounded extent there. @JsonKey(includeFromJson: false) @override - AxisC get scrollDirection => - rowColumnType == RowColumnType.row ? AxisC.horizontal : AxisC.vertical; + AxisC get scrollDirection => (rowColumnType == RowColumnType.row) != wrap + ? AxisC.horizontal + : AxisC.vertical; @override set scrollDirection(AxisC value) {} @@ -34,6 +39,9 @@ class RowColumnNode extends ParentNode required RowColumnType rowColumnType, MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.center, CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.center, + double spacing = 0, + bool wrap = false, + double runSpacing = 0, super.retainedOuterBoxLocal, super.visible, super.enabled, @@ -86,6 +94,9 @@ class RowColumnNode extends ParentNode rowColumnType: rowColumnType, mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: crossAxisAlignment, + spacing: spacing, + wrap: wrap, + runSpacing: runSpacing, ); setScrollableMixin( @@ -101,6 +112,37 @@ class RowColumnNode extends ParentNode ); } + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'spacing': NumFieldAccess( + 'Spacing', + 'Gap between consecutive children along the main axis.', + () => spacing, + (value) => spacing = value, + defaultValue: () => 0, + min: 0, + requiresLayout: true, + ), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether children wrap onto further runs when they do not fit.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Spacing', + 'Gap between runs along the cross axis when wrapping.', + () => runSpacing, + (value) => runSpacing = value, + defaultValue: () => 0, + min: 0, + requiresLayout: true, + ), + }; + /// Creates a [RowColumnNode] from a JSON data. factory RowColumnNode.fromJson(Map json) => _$RowColumnNodeFromJson(json); diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 4385e68c..ccfdfcd2 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -68,6 +68,9 @@ Map _$RowColumnNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); if (isJsonDefault(instance.primary, true)) json.remove('primary'); @@ -119,6 +122,9 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => json['crossAxisAlignment'], ) ?? CrossAxisAlignmentC.center, + spacing: (json['spacing'] as num?)?.toDouble() ?? 0.0, + wrap: json['wrap'] as bool? ?? false, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0.0, visible: json['visible'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true, rotationDegrees: json['rotation'] == null @@ -328,6 +334,9 @@ Map _$RowColumnNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'isScrollable': instance.isScrollable, 'reverse': instance.reverse, 'primary': instance.primary, diff --git a/test/computed_variable_value_test.dart b/test/computed_variable_value_test.dart new file mode 100644 index 00000000..bb4bd10d --- /dev/null +++ b/test/computed_variable_value_test.dart @@ -0,0 +1,15 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a formula value survives sanitizing for every type', () { + const String formula = r"${= find(trips, 'id', selected)}"; + for (final VariableType type in VariableType.values) { + expect( + VariableData(id: 'v', name: 'v', type: type, value: formula).value, + formula, + reason: type.name, + ); + } + }); +} diff --git a/test/row_column_spacing_test.dart b/test/row_column_spacing_test.dart new file mode 100644 index 00000000..1f7323d8 --- /dev/null +++ b/test/row_column_spacing_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +RowColumnNode _row({double spacing = 0}) => RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 1072, 100), + children: const ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + spacing: spacing, +); + +void main() { + group('RowColumnNode.spacing', () { + test('defaults to 0 and is omitted from JSON', () { + final node = _row(); + expect(node.spacing, 0); + expect(node.toJson().containsKey('spacing'), isFalse); + }); + + test('round-trips a non-zero spacing', () { + final json = jsonDecode(jsonEncode(_row(spacing: 20).toJson())) as Map; + expect(json['spacing'], 20); + final decoded = RowColumnNode.fromJson(json); + expect(decoded.spacing, 20); + expect(decoded.toJson()['spacing'], 20); + }); + + test('loads JSON written without spacing', () { + final json = _row().toJson()..remove('spacing'); + expect(RowColumnNode.fromJson(json).spacing, 0); + }); + + test('accepts integer spacing in JSON', () { + final json = _row().toJson()..['spacing'] = 12; + expect(RowColumnNode.fromJson(json).spacing, 12.0); + }); + + test('is exposed as a layout field', () { + final node = _row(spacing: 8); + final field = node.fields['spacing']!; + expect(field.getValue(), 8); + expect(field.requiresLayout, isTrue); + field.setValue(16.0); + expect(node.spacing, 16); + }); + + test('other RowColumnMixin nodes load old JSON without spacing', () { + final placeholder = AutoPlaceholderNode( + id: 'placeholder', + name: 'Placeholder', + basicBoxLocal: NodeBox(0, 0, 100, 100), + children: const [], + ); + final json = placeholder.toJson(); + expect(json.containsKey('spacing'), isFalse); + expect(AutoPlaceholderNode.fromJson(json).spacing, 0); + }); + }); +} diff --git a/test/row_column_wrap_test.dart b/test/row_column_wrap_test.dart new file mode 100644 index 00000000..ded54921 --- /dev/null +++ b/test/row_column_wrap_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +RowColumnNode _row({bool wrap = false, double runSpacing = 0}) => + RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 330, 100), + children: const ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + spacing: 8, + wrap: wrap, + runSpacing: runSpacing, + ); + +void main() { + group('RowColumnNode wrap', () { + test('defaults to no wrap and is omitted from JSON', () { + final node = _row(); + expect(node.wrap, isFalse); + expect(node.runSpacing, 0); + final json = node.toJson(); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + }); + + test('round-trips wrap and runSpacing', () { + final json = + jsonDecode(jsonEncode(_row(wrap: true, runSpacing: 12).toJson())) + as Map; + expect(json['wrap'], isTrue); + expect(json['runSpacing'], 12); + final decoded = RowColumnNode.fromJson(json); + expect(decoded.wrap, isTrue); + expect(decoded.runSpacing, 12); + expect(decoded.spacing, 8); + }); + + test('loads JSON written without wrap; accepts integer runSpacing', () { + final json = _row().toJson()..['runSpacing'] = 4; + final decoded = RowColumnNode.fromJson(json); + expect(decoded.wrap, isFalse); + expect(decoded.runSpacing, 4.0); + }); + + test('is exposed as layout fields', () { + final node = _row(); + expect(node.fields['wrap']!.requiresLayout, isTrue); + expect(node.fields['runSpacing']!.requiresLayout, isTrue); + node.fields['wrap']!.setValue(true); + node.fields['runSpacing']!.setValue(6.0); + expect(node.wrap, isTrue); + expect(node.runSpacing, 6); + }); + + test('a wrapping row/column scrolls along its cross axis', () { + expect(_row().scrollDirection, AxisC.horizontal); + expect(_row(wrap: true).scrollDirection, AxisC.vertical); + final column = _row(wrap: true)..rowColumnType = RowColumnType.column; + expect(column.scrollDirection, AxisC.horizontal); + }); + + test('other RowColumnMixin nodes load old JSON without wrap', () { + final placeholder = AutoPlaceholderNode( + id: 'placeholder', + name: 'Placeholder', + basicBoxLocal: NodeBox(0, 0, 100, 100), + children: const [], + ); + final json = placeholder.toJson(); + expect(json.containsKey('wrap'), isFalse); + final decoded = AutoPlaceholderNode.fromJson(json); + expect(decoded.wrap, isFalse); + expect(decoded.runSpacing, 0); + }); + }); +}