Skip to content
Merged
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
98 changes: 69 additions & 29 deletions lib/shared/widgets/querya_tab_strip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/motion/querya_motion.dart';
import 'package:querya_desktop/core/motion/querya_motion_context.dart';
import 'package:querya_desktop/core/motion/querya_spring.dart';
import 'package:querya_desktop/core/motion/querya_spring_controller.dart';
import 'package:querya_desktop/core/ui/querya_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

/// A compact, keyboard-operable tab strip using Querya's motion and theme.
Expand Down Expand Up @@ -172,9 +173,21 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
_focusNodes[index].requestFocus();
}

bool _canCloseAt(int index) =>
widget.onClose != null &&
(widget.canClose == null || widget.canClose!(index));

material.KeyEventResult _onKeyEvent(int index, KeyEvent event) {
if (event is! KeyDownEvent) return material.KeyEventResult.ignored;

// Delete / Backspace close the focused tab, as the close button does.
if (event.logicalKey == LogicalKeyboardKey.delete ||
event.logicalKey == LogicalKeyboardKey.backspace) {
if (!_canCloseAt(index)) return material.KeyEventResult.ignored;
widget.onClose!(index);
return material.KeyEventResult.handled;
}

final last = widget.labels.length - 1;
final int? target = switch (event.logicalKey) {
LogicalKeyboardKey.arrowLeft => index == 0 ? last : index - 1,
Expand Down Expand Up @@ -244,8 +257,7 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
final selected = widget.selectedIndex == index;
final focused = _focused[index];
final label = widget.labels[index];
final showClose = widget.onClose != null &&
(widget.canClose == null || widget.canClose!(index));
final showClose = _canCloseAt(index);
return material.Padding(
padding: material.EdgeInsets.only(left: index == 0 ? 0 : 6),
child: material.KeyedSubtree(
Expand All @@ -260,8 +272,7 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
selected: selected,
label: label,
onTap: () => _selectAndFocus(index),
child: material.ExcludeSemantics(
child: material.MouseRegion(
child: material.MouseRegion(
cursor: material.SystemMouseCursors.click,
child: material.GestureDetector(
excludeFromSemantics: true,
Expand All @@ -271,10 +282,20 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
key: material.ValueKey('querya_tab_$label'),
duration: context.motionDuration(QueryaMotion.fast),
curve: context.motionCurve(QueryaMotion.enter),
padding: material.EdgeInsets.symmetric(
horizontal: showClose ? 10 : 12,
vertical: widget.dense ? 4 : 8,
),
// The close target is 24 px (#1372); the padding
// around it shrinks so the tab keeps its height
// and width.
padding: showClose
? material.EdgeInsets.fromLTRB(
10,
widget.dense ? 2 : 6,
5,
widget.dense ? 2 : 6,
)
: material.EdgeInsets.symmetric(
horizontal: 12,
vertical: widget.dense ? 4 : 8,
),
decoration: material.BoxDecoration(
color: material.Colors.transparent,
borderRadius: material.BorderRadius.circular(6),
Expand All @@ -288,28 +309,48 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
child: material.Row(
mainAxisSize: material.MainAxisSize.min,
children: [
selected
? Text(label).small().semiBold()
: Text(label).small().muted(),
// The tab's own node carries the label.
material.ExcludeSemantics(
child: selected
? Text(label).small().semiBold()
: Text(label).small().muted(),
),
if (showClose) ...[
const Gap(6),
material.MouseRegion(
cursor: material.SystemMouseCursors.click,
child: material.GestureDetector(
behavior: material.HitTestBehavior.opaque,
const Gap(2),
material.Tooltip(
message: 'Close $label tab',
waitDuration: kQueryaTooltipWait,
excludeFromSemantics: true,
child: material.Semantics(
container: true,
button: true,
label: 'Close $label tab',
onTap: () => widget.onClose!(index),
child: material.Container(
padding: const material.EdgeInsets.all(1),
decoration: material.BoxDecoration(
borderRadius:
material.BorderRadius.circular(4),
),
child: material.Icon(
material.Icons.close_rounded,
size: 13,
color: selected
? colors.foreground
: colors.mutedForeground,
child: material.ExcludeSemantics(
child: material.MouseRegion(
cursor:
material.SystemMouseCursors.click,
child: material.GestureDetector(
excludeFromSemantics: true,
behavior:
material.HitTestBehavior.opaque,
onTap: () => widget.onClose!(index),
child: material.SizedBox(
key: material.ValueKey(
'querya_tab_close_$label'),
width: 24,
height: 24,
child: material.Center(
child: material.Icon(
material.Icons.close_rounded,
size: 13,
color: selected
? colors.foreground
: colors.mutedForeground,
),
),
),
),
),
),
),
Expand All @@ -323,7 +364,6 @@ class QueryaTabStripState extends material.State<QueryaTabStrip>
),
),
),
),
);
}),
if (widget.onAdd != null) ...[
Expand Down
79 changes: 79 additions & 0 deletions test/shared/querya_tab_strip_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,83 @@ void main() {

expect(scrollController.offset, greaterThan(0.0));
});

group('closing tabs (#1372)', () {
Future<List<int>> pumpClosable(
WidgetTester tester, {
bool Function(int index)? canClose,
}) async {
final closed = <int>[];
await tester.pumpWidget(
stripShell(
child: material.Center(
child: QueryaTabStrip(
labels: const ['Server', 'SQL', 'History'],
selectedIndex: 0,
onSelected: (_) {},
onClose: closed.add,
canClose: canClose,
),
),
),
);
await tester.pump();
await tester.pumpAndSettle();
return closed;
}

testWidgets('Delete closes the focused tab', (tester) async {
final closed = await pumpClosable(tester);

await tester.tap(find.bySemanticsLabel('SQL'));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
await tester.pump();

expect(closed, [1]);
});

testWidgets('Backspace closes the focused tab', (tester) async {
final closed = await pumpClosable(tester);

await tester.tap(find.bySemanticsLabel('History'));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
await tester.pump();

expect(closed, [2]);
});

testWidgets('Delete does nothing for a tab that cannot be closed',
(tester) async {
final closed = await pumpClosable(tester, canClose: (i) => i != 1);

await tester.tap(find.bySemanticsLabel('SQL'));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
await tester.pump();

expect(closed, isEmpty);
});

testWidgets('the close button has a 24 px target and a labelled button',
(tester) async {
final closed = await pumpClosable(tester);

final target = tester.getSize(
find.byKey(const material.ValueKey('querya_tab_close_SQL')),
);
expect(target.width, greaterThanOrEqualTo(24));
expect(target.height, greaterThanOrEqualTo(24));

expect(find.bySemanticsLabel('Close SQL tab'), findsOneWidget);
expect(find.byTooltip('Close SQL tab'), findsOneWidget);

await tester.tap(
find.byKey(const material.ValueKey('querya_tab_close_SQL')),
);
await tester.pump();
expect(closed, [1]);
});
});
}
Loading