Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/remix/lib/src/components/dialog/dialog.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/remix/lib/src/components/dialog/dialog_style.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension RemixDialogStylerRemixHelpers on RemixDialogStyler {
String? title,
String? description,
List<Widget>? actions,
bool scrollable = false,
bool modal = true,
String? semanticLabel,
}) {
Expand All @@ -26,6 +27,7 @@ extension RemixDialogStylerRemixHelpers on RemixDialogStyler {
title: title,
description: description,
actions: actions,
scrollable: scrollable,
modal: modal,
semanticLabel: semanticLabel,
style: this,
Expand Down
66 changes: 53 additions & 13 deletions packages/remix/lib/src/components/dialog/dialog_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class RemixDialog extends StatelessWidget {
this.title,
this.description,
this.actions,
this.scrollable = false,
this.modal = true,
this.semanticLabel,
this.style = const RemixDialogStyler.create(),
Expand All @@ -154,6 +155,13 @@ class RemixDialog extends StatelessWidget {
/// Action buttons (typically placed at the bottom).
final List<Widget>? actions;

/// Whether structured body content can scroll within a bounded dialog.
///
/// When true, [description] and [child] share a vertical scroll region while
/// [title] and [actions] remain outside it. A lone [child] remains fully
/// caller-owned and is never wrapped in the structured scroll region.
final bool scrollable;

/// Whether to block background content interaction.
final bool modal;

Expand Down Expand Up @@ -186,22 +194,54 @@ class RemixDialog extends StatelessWidget {
return Box(styleSpec: spec.container, child: child!);
}

final hasBody = description != null || child != null;
final titleWidget = title == null
? null
: StyledText(title!, styleSpec: spec.title);
final bodyChildren = <Widget>[
if (description != null)
StyledText(description!, styleSpec: spec.description),
?child,
];
final actionsWidget = hasActions
? FlexBox(styleSpec: spec.actions, children: actions!)
: null;

// title → description → child → actions; never discard provided content.
return Box(
styleSpec: spec.container,
child: Column(
mainAxisAlignment: .start,
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
if (title != null) StyledText(title!, styleSpec: spec.title),
if (description != null)
StyledText(description!, styleSpec: spec.description),
?child,
if (hasActions)
FlexBox(styleSpec: spec.actions, children: actions!),
],
),
child: scrollable && hasBody
? LayoutBuilder(
builder: (context, constraints) {
final body = SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: bodyChildren,
),
);

return Column(
mainAxisAlignment: .start,
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
?titleWidget,
if (constraints.hasBoundedHeight)
Flexible(fit: FlexFit.loose, child: body)
else
body,
?actionsWidget,
],
);
},
)
: Column(
mainAxisAlignment: .start,
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [?titleWidget, ...bodyChildren, ?actionsWidget],
),
);
},
);
Expand Down
2 changes: 2 additions & 0 deletions packages/remix/test/components/dialog/dialog_style_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ void main() {
title: 'Confirm',
description: 'Continue?',
actions: const [Text('OK')],
scrollable: true,
modal: false,
semanticLabel: 'Confirmation dialog',
);
Expand All @@ -320,6 +321,7 @@ void main() {
expect(dialog.title, 'Confirm');
expect(dialog.description, 'Continue?');
expect(dialog.actions, hasLength(1));
expect(dialog.scrollable, isTrue);
expect(dialog.modal, isFalse);
expect(dialog.semanticLabel, 'Confirmation dialog');
});
Expand Down
139 changes: 138 additions & 1 deletion packages/remix/test/components/dialog/dialog_widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,13 @@ void main() {
});

testWidgets('a lone child fills the container directly', (tester) async {
await tester.pumpRemixApp(RemixDialog(child: Text('Only child')));
await tester.pumpRemixApp(
RemixDialog(child: Text('Only child'), scrollable: true),
);
await tester.pumpAndSettle();

expect(find.text('Only child'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsNothing);
expect(
find.descendant(of: find.byType(Box), matching: find.byType(Column)),
findsNothing,
Expand Down Expand Up @@ -482,9 +485,143 @@ void main() {
expect(titleY, lessThan(descY));
expect(descY, lessThan(bodyY));
expect(bodyY, lessThan(actionY));
expect(find.byType(SingleChildScrollView), findsNothing);
},
);

testWidgets('bounded large-text structured content does not overflow', (
tester,
) async {
final semantics = tester.ensureSemantics();
final bodyFocus = FocusNode(debugLabel: 'final environment variable');
final actionFocus = FocusNode(debugLabel: 'save environment');
addTearDown(bodyFocus.dispose);
addTearDown(actionFocus.dispose);

try {
await tester.pumpRemixApp(
MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: SizedBox(
width: 400,
height: 320,
child: FortalDialog(
title: 'Environment',
description: 'Variables available to this workspace.',
scrollable: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
8,
(index) => index == 7
? TextButton(
focusNode: bodyFocus,
onPressed: () {},
child: const Text('Environment variable 8'),
)
: Text('Environment variable ${index + 1}'),
),
),
actions: [
IconButton(
key: const ValueKey('save-environment-action'),
focusNode: actionFocus,
tooltip: 'Save environment',
onPressed: () {},
icon: const Icon(Icons.save),
),
],
),
),
),
);
await tester.pumpAndSettle();

expect(tester.takeException(), isNull);
expect(
tester.widget<FortalDialog>(find.byType(FortalDialog)).scrollable,
isTrue,
);
expect(
tester.widget<RemixDialog>(find.byType(RemixDialog)).scrollable,
isTrue,
);

final scrollView = find.byType(SingleChildScrollView);
final action = find.byKey(const ValueKey('save-environment-action'));
expect(scrollView, findsOneWidget);
expect(
find.descendant(of: scrollView, matching: find.text('Environment')),
findsNothing,
);
expect(
find.descendant(of: scrollView, matching: action),
findsNothing,
);

final scrollable = find.descendant(
of: scrollView,
matching: find.byType(Scrollable),
);
final position = tester.state<ScrollableState>(scrollable).position;
final actionY = tester.getTopLeft(action).dy;
expect(position.maxScrollExtent, greaterThan(0));

await tester.drag(scrollView, const Offset(0, -1000));
await tester.pumpAndSettle();

expect(position.pixels, greaterThan(0));
expect(tester.getTopLeft(action).dy, closeTo(actionY, 0.01));
expect(
find.bySemanticsLabel('Environment variable 8'),
findsOneWidget,
);
expect(
tester.getSemantics(action),
isSemantics(
tooltip: 'Save environment',
isButton: true,
hasTapAction: true,
),
);

bodyFocus.requestFocus();
await tester.pumpAndSettle();
expect(bodyFocus.hasFocus, isTrue);

for (
var attempt = 0;
attempt < 2 && !actionFocus.hasFocus;
attempt++
) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
}
expect(actionFocus.hasFocus, isTrue);
} finally {
semantics.dispose();
}
});

testWidgets('scrollable content shrink-wraps unbounded height', (
tester,
) async {
await tester.pumpRemixApp(
SingleChildScrollView(
child: RemixDialog(
title: 'Environment',
description: 'Variables available to this workspace.',
scrollable: true,
),
),
);
await tester.pumpAndSettle();

expect(tester.takeException(), isNull);
expect(find.byType(SingleChildScrollView), findsNWidgets(2));
expect(find.byType(Flexible), findsNothing);
});

testWidgets('title and description are rendered together', (
tester,
) async {
Expand Down
Loading