Skip to content

Commit b0a70ad

Browse files
committed
ShowDialogAction: anchor a dialog to its trigger as a popover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8
1 parent 081d194 commit b0a70ad

4 files changed

Lines changed: 261 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
- `PaintModel.gradientTileMode` (clamp, repeated, mirror) for repeating
1515
gradients such as stripes.
1616
- `BlendMixin.pulse` (`PulseModel`): a looping fade of a node's opacity.
17+
- `ShowDialogAction.anchor` (`DialogAnchor.trigger`), `placement`,
18+
`anchorGap` and `anchorOffset`: a dialog can open as a popover next to
19+
the node that ran it. `copyWith` keeps `nonBlocking` and `enabled`.
1720
- `ShowToastAction.accentColor` (a 4 px leading edge), `cornerRadius` and
1821
`maxWidth`.
1922
- Slider thumbs can be rectangles: `SliderProperties.thumbShape`,

‎lib/src/api/models/action/show_dialog_action.dart‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,94 @@ import '../models.dart';
66

77
part 'show_dialog_action.g.dart';
88

9-
/// An action that navigates to a new page.
9+
/// Where a [ShowDialogAction] shows its canvas.
10+
enum DialogAnchor {
11+
/// Centered on the screen.
12+
none,
13+
14+
/// Next to the node that ran the action, like a popover or menu, at the
15+
/// action's [ShowDialogAction.placement].
16+
trigger;
17+
18+
/// Displayable name.
19+
String get prettify => switch (this) {
20+
DialogAnchor.none => 'Center of screen',
21+
DialogAnchor.trigger => 'Anchor to trigger',
22+
};
23+
}
24+
25+
/// The side of the trigger an anchored [ShowDialogAction] opens on, and how
26+
/// it lines up with the trigger along that side.
27+
///
28+
/// Start and end follow the reading direction for [below] and [above]
29+
/// placements (start is the left edge in a left-to-right app) and are the top
30+
/// and bottom edges for [right] and [left] placements. When the dialog does
31+
/// not fit on its side, it opens on the opposite side if there is more room
32+
/// there.
33+
enum DialogPlacement {
34+
/// Below the trigger, start edges lined up.
35+
belowStart,
36+
37+
/// Below the trigger, centered on it.
38+
belowCenter,
39+
40+
/// Below the trigger, end edges lined up.
41+
belowEnd,
42+
43+
/// Above the trigger, start edges lined up.
44+
aboveStart,
45+
46+
/// Above the trigger, centered on it.
47+
aboveCenter,
48+
49+
/// Above the trigger, end edges lined up.
50+
aboveEnd,
51+
52+
/// Right of the trigger, top edges lined up.
53+
rightStart,
54+
55+
/// Right of the trigger, centered on it.
56+
rightCenter,
57+
58+
/// Right of the trigger, bottom edges lined up.
59+
rightEnd,
60+
61+
/// Left of the trigger, top edges lined up.
62+
leftStart,
63+
64+
/// Left of the trigger, centered on it.
65+
leftCenter,
66+
67+
/// Left of the trigger, bottom edges lined up.
68+
leftEnd;
69+
70+
/// Whether the dialog opens below or above the trigger, rather than beside
71+
/// it.
72+
bool get isVertical => index <= DialogPlacement.aboveEnd.index;
73+
74+
/// Displayable name.
75+
String get prettify => switch (this) {
76+
DialogPlacement.belowStart => 'Below, start',
77+
DialogPlacement.belowCenter => 'Below, center',
78+
DialogPlacement.belowEnd => 'Below, end',
79+
DialogPlacement.aboveStart => 'Above, start',
80+
DialogPlacement.aboveCenter => 'Above, center',
81+
DialogPlacement.aboveEnd => 'Above, end',
82+
DialogPlacement.rightStart => 'Right, top',
83+
DialogPlacement.rightCenter => 'Right, center',
84+
DialogPlacement.rightEnd => 'Right, bottom',
85+
DialogPlacement.leftStart => 'Left, top',
86+
DialogPlacement.leftCenter => 'Left, center',
87+
DialogPlacement.leftEnd => 'Left, bottom',
88+
};
89+
}
90+
91+
/// An action that shows a canvas as a dialog: centered on the screen, or,
92+
/// with [anchor] set to [DialogAnchor.trigger], as a popover next to the node
93+
/// that ran it (an account menu under an avatar, for example).
94+
///
95+
/// The dialog closes when its barrier is tapped (if [barrierDismissible]) or
96+
/// with a "Navigate back" action inside it.
1097
@JsonSerializable()
1198
class ShowDialogAction extends ActionModel with Equatable, SerializableMixin {
1299
/// Destination canvas node's ID.
@@ -24,13 +111,36 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin {
24111
/// Whether to show a close button on the dialog.
25112
final bool showCloseButton;
26113

114+
/// Where the dialog shows: centered, or next to the node that ran the
115+
/// action.
116+
@JsonKey(unknownEnumValue: DialogAnchor.none)
117+
final DialogAnchor anchor;
118+
119+
/// The side of the trigger an anchored dialog opens on. Only used when
120+
/// [anchor] is [DialogAnchor.trigger].
121+
@JsonKey(unknownEnumValue: DialogPlacement.belowStart)
122+
final DialogPlacement placement;
123+
124+
/// The space between the trigger and an anchored dialog, in logical
125+
/// pixels.
126+
final double anchorGap;
127+
128+
/// How far an anchored dialog is shifted along the trigger's edge, in
129+
/// logical pixels: toward the end for [DialogPlacement.isVertical]
130+
/// placements (right in a left-to-right app), down for the others.
131+
final double anchorOffset;
132+
27133
/// Creates a new [ShowDialogAction].
28134
ShowDialogAction({
29135
required this.destinationId,
30136
Map<String, String>? params,
31137
this.barrierColor = ColorRGBA.black54,
32138
this.barrierDismissible = true,
33139
this.showCloseButton = false,
140+
this.anchor = DialogAnchor.none,
141+
this.placement = DialogPlacement.belowStart,
142+
this.anchorGap = 4,
143+
this.anchorOffset = 0,
34144
super.nonBlocking,
35145
super.enabled,
36146
}) : params = {...params ?? {}},
@@ -43,21 +153,38 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin {
43153
barrierDismissible,
44154
barrierColor,
45155
showCloseButton,
156+
anchor,
157+
placement,
158+
anchorGap,
159+
anchorOffset,
46160
];
47161

162+
/// Whether the dialog opens next to the node that ran the action.
163+
bool get isAnchored => anchor == DialogAnchor.trigger;
164+
48165
/// Duplicates this [ShowDialogAction] with given data overrides.
49166
ShowDialogAction copyWith({
50167
String? destinationId,
51168
Map<String, String>? params,
52169
bool? barrierDismissible,
53170
ColorRGBA? barrierColor,
54171
bool? showCloseButton,
172+
DialogAnchor? anchor,
173+
DialogPlacement? placement,
174+
double? anchorGap,
175+
double? anchorOffset,
55176
}) => ShowDialogAction(
56177
destinationId: destinationId ?? this.destinationId,
57178
params: {...this.params, ...params ?? {}},
58179
barrierDismissible: barrierDismissible ?? this.barrierDismissible,
59180
barrierColor: barrierColor ?? this.barrierColor,
60181
showCloseButton: showCloseButton ?? this.showCloseButton,
182+
anchor: anchor ?? this.anchor,
183+
placement: placement ?? this.placement,
184+
anchorGap: anchorGap ?? this.anchorGap,
185+
anchorOffset: anchorOffset ?? this.anchorOffset,
186+
nonBlocking: nonBlocking,
187+
enabled: enabled,
61188
);
62189

63190
/// Creates a new [ShowDialogAction] instance from a JSON data.

‎lib/src/api/models/action/show_dialog_action.g.dart‎

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/show_dialog_action_test.dart‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'package:codelessly_api/codelessly_api.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
test('A saved dialog action without an anchor stays centered', () {
6+
final Map<String, dynamic> saved = {
7+
'type': 'showDialog',
8+
'destinationId': 'canvas-1',
9+
'params': <String, dynamic>{},
10+
};
11+
final action = ActionModel.fromJson(saved) as ShowDialogAction;
12+
expect(action.anchor, DialogAnchor.none);
13+
expect(action.isAnchored, isFalse);
14+
expect(action.placement, DialogPlacement.belowStart);
15+
expect(action.anchorGap, 4);
16+
expect(action.anchorOffset, 0);
17+
// Defaults stay out of the JSON, so old projects save unchanged.
18+
expect(action.toJson(), saved);
19+
});
20+
21+
test('An anchored dialog action round-trips its placement', () {
22+
final action = ShowDialogAction(
23+
destinationId: 'menu',
24+
anchor: DialogAnchor.trigger,
25+
placement: DialogPlacement.belowEnd,
26+
anchorGap: 8,
27+
anchorOffset: -2,
28+
barrierColor: ColorRGBA.transparent,
29+
);
30+
final Map json = action.toJson();
31+
expect(json['anchor'], 'trigger');
32+
expect(json['placement'], 'belowEnd');
33+
expect(json['anchorGap'], 8);
34+
expect(json['anchorOffset'], -2);
35+
final decoded = ActionModel.fromJson(json) as ShowDialogAction;
36+
expect(decoded, action);
37+
expect(decoded.isAnchored, isTrue);
38+
39+
// Only what differs from the defaults is written.
40+
final Map onlyAnchor = ShowDialogAction(
41+
destinationId: 'menu',
42+
anchor: DialogAnchor.trigger,
43+
).toJson();
44+
expect(onlyAnchor.containsKey('placement'), isFalse);
45+
expect(onlyAnchor.containsKey('anchorGap'), isFalse);
46+
expect(onlyAnchor.containsKey('anchorOffset'), isFalse);
47+
});
48+
49+
test(
50+
'Unknown anchors and placements from a newer editor read as defaults',
51+
() {
52+
final action = ActionModel.fromJson({
53+
'type': 'showDialog',
54+
'destinationId': 'menu',
55+
'anchor': 'cursor',
56+
'placement': 'diagonal',
57+
}) as ShowDialogAction;
58+
expect(action.anchor, DialogAnchor.none);
59+
expect(action.placement, DialogPlacement.belowStart);
60+
},
61+
);
62+
63+
test('copyWith keeps the anchor and the enabled and blocking flags', () {
64+
final action = ShowDialogAction(
65+
destinationId: 'menu',
66+
anchor: DialogAnchor.trigger,
67+
placement: DialogPlacement.aboveCenter,
68+
nonBlocking: true,
69+
enabled: false,
70+
);
71+
final copy = action.copyWith(showCloseButton: true);
72+
expect(copy.anchor, DialogAnchor.trigger);
73+
expect(copy.placement, DialogPlacement.aboveCenter);
74+
expect(copy.nonBlocking, isTrue);
75+
expect(copy.enabled, isFalse);
76+
expect(copy.copyWith(anchor: DialogAnchor.none).isAnchored, isFalse);
77+
});
78+
79+
test('Placements know their axis', () {
80+
expect(DialogPlacement.belowStart.isVertical, isTrue);
81+
expect(DialogPlacement.aboveEnd.isVertical, isTrue);
82+
expect(DialogPlacement.rightStart.isVertical, isFalse);
83+
expect(DialogPlacement.leftEnd.isVertical, isFalse);
84+
});
85+
}

0 commit comments

Comments
 (0)