MultitabPane: drag-to-reorder gesture wiring (T-84)
Each tab is wrapped in a Draggable (when allowReorder is true and the entry itself is reorderable) and a DragTarget (always — the controller's barrier logic decides whether the move actually happens). Drops insert the dragged entry at the target tab's index. A 2px leading insertion indicator highlights the active drop target. The widget harness now wraps children in an Overlay so Draggable's feedback can mount without each test re-wrapping. Sized by the test view's bounds to avoid disturbing existing tests that query find.byType(SizedBox).first. Four widget tests cover the gesture path: drop reorders, pinned barrier blocks, pinned tabs aren't draggable, and allowReorder=false disables drag entirely. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+27
-4
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"exported_at": "2026-05-06T10:12:42Z",
|
||||
"exported_at": "2026-05-06T12:17:16Z",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "D-1",
|
||||
@@ -2678,15 +2678,24 @@
|
||||
"id": "T-83",
|
||||
"type": "task",
|
||||
"title": "design reusable sortable tab system for multitab panes",
|
||||
"status": "in_progress",
|
||||
"status": "done",
|
||||
"priority": "high",
|
||||
"created_at": "2026-05-06 09:17:39",
|
||||
"updated_at": "2026-05-06 09:17:50"
|
||||
"updated_at": "2026-05-06 10:12:48"
|
||||
},
|
||||
{
|
||||
"id": "T-84",
|
||||
"type": "task",
|
||||
"title": "MultitabPane: drag-to-reorder gesture wiring",
|
||||
"status": "in_progress",
|
||||
"priority": "medium",
|
||||
"created_at": "2026-05-06 10:16:02",
|
||||
"updated_at": "2026-05-06 10:17:57"
|
||||
}
|
||||
],
|
||||
"ticket_deps": [
|
||||
{
|
||||
"blocker_id": "T-83",
|
||||
"blocker_id": "T-84",
|
||||
"blocked_id": "T-24"
|
||||
}
|
||||
],
|
||||
@@ -4033,6 +4042,20 @@
|
||||
"old_value": "backlog",
|
||||
"new_value": "in_progress",
|
||||
"changed_at": "2026-05-06 09:17:50"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-83",
|
||||
"field": "status",
|
||||
"old_value": "in_progress",
|
||||
"new_value": "done",
|
||||
"changed_at": "2026-05-06 10:12:48"
|
||||
},
|
||||
{
|
||||
"ticket_id": "T-84",
|
||||
"field": "status",
|
||||
"old_value": "backlog",
|
||||
"new_value": "in_progress",
|
||||
"changed_at": "2026-05-06 10:17:57"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -25,9 +25,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
START / RECENT row when the viewport is tall enough.
|
||||
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||
N runtime tab instances of the same kind. Generic over a payload
|
||||
type, supports pinned/non-closeable tabs (primary), drag-reorder
|
||||
(planned), close × on hover, and an optional `+` add button. Used
|
||||
by the Claude pane to render primary + secondaries.
|
||||
type, supports pinned/non-closeable tabs (primary), drag-reorder,
|
||||
close × on hover, and an optional `+` add button. Used by the
|
||||
Claude pane to render primary + secondaries.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -94,20 +94,24 @@ class _TabStrip<T> extends StatelessWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final entry in entries)
|
||||
_Tab<T>(
|
||||
entry: entry,
|
||||
active: entry.id == activeId,
|
||||
onSelect: () => controller.activate(entry.id),
|
||||
onClose: entry.closeable
|
||||
for (var i = 0; i < entries.length; i++)
|
||||
_ReorderableTab<T>(
|
||||
entry: entries[i],
|
||||
index: i,
|
||||
active: entries[i].id == activeId,
|
||||
allowReorder: allowReorder,
|
||||
onSelect: () => controller.activate(entries[i].id),
|
||||
onClose: entries[i].closeable
|
||||
? () {
|
||||
if (onCloseRequested != null) {
|
||||
onCloseRequested!(entry);
|
||||
onCloseRequested!(entries[i]);
|
||||
} else {
|
||||
controller.remove(entry.id);
|
||||
controller.remove(entries[i].id);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onReorderTo: (draggedId) =>
|
||||
controller.reorder(draggedId, i),
|
||||
tabHeight: tabHeight,
|
||||
),
|
||||
if (onAddRequested != null)
|
||||
@@ -119,6 +123,127 @@ class _TabStrip<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a [_Tab] with [Draggable] (when [allowReorder] is true and the
|
||||
/// entry itself permits reorder) and [DragTarget] (always — the
|
||||
/// controller's barrier logic decides whether a drop actually moves
|
||||
/// the tab). Drop target inserts the dragged id at this tab's index.
|
||||
class _ReorderableTab<T> extends StatefulWidget {
|
||||
const _ReorderableTab({
|
||||
required this.entry,
|
||||
required this.index,
|
||||
required this.active,
|
||||
required this.allowReorder,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
required this.onReorderTo,
|
||||
required this.tabHeight,
|
||||
});
|
||||
|
||||
final MultitabEntry<T> entry;
|
||||
final int index;
|
||||
final bool active;
|
||||
final bool allowReorder;
|
||||
final VoidCallback onSelect;
|
||||
final VoidCallback? onClose;
|
||||
final void Function(String draggedId) onReorderTo;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
State<_ReorderableTab<T>> createState() => _ReorderableTabState<T>();
|
||||
}
|
||||
|
||||
class _ReorderableTabState<T> extends State<_ReorderableTab<T>> {
|
||||
bool _isDropTarget = false;
|
||||
|
||||
bool get _draggable => widget.allowReorder && widget.entry.reorderable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
final tabContent = _Tab<T>(
|
||||
entry: widget.entry,
|
||||
active: widget.active,
|
||||
onSelect: widget.onSelect,
|
||||
onClose: widget.onClose,
|
||||
tabHeight: widget.tabHeight,
|
||||
);
|
||||
|
||||
Widget result = DragTarget<String>(
|
||||
onWillAcceptWithDetails: (d) {
|
||||
if (d.data == widget.entry.id) return false;
|
||||
return widget.allowReorder;
|
||||
},
|
||||
onMove: (_) {
|
||||
if (!_isDropTarget) setState(() => _isDropTarget = true);
|
||||
},
|
||||
onLeave: (_) {
|
||||
if (_isDropTarget) setState(() => _isDropTarget = false);
|
||||
},
|
||||
onAcceptWithDetails: (d) {
|
||||
setState(() => _isDropTarget = false);
|
||||
widget.onReorderTo(d.data);
|
||||
},
|
||||
builder: (context, _, __) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 2,
|
||||
height: widget.tabHeight,
|
||||
child: ColoredBox(
|
||||
color: _isDropTarget ? tokens.panelActiveBorder : const Color(0x00000000),
|
||||
),
|
||||
),
|
||||
tabContent,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (_draggable) {
|
||||
result = Draggable<String>(
|
||||
data: widget.entry.id,
|
||||
axis: Axis.horizontal,
|
||||
feedback: _DragFeedback(
|
||||
title: widget.entry.title,
|
||||
tabHeight: widget.tabHeight,
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.4, child: tabContent),
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _DragFeedback extends StatelessWidget {
|
||||
const _DragFeedback({required this.title, required this.tabHeight});
|
||||
final String title;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
height: tabHeight,
|
||||
constraints: const BoxConstraints(minWidth: 96, maxWidth: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelHeader,
|
||||
border: Border.all(color: tokens.panelActiveBorder),
|
||||
),
|
||||
child: ClideText(
|
||||
title,
|
||||
fontSize: 12,
|
||||
color: tokens.tabActiveForeground,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab<T> extends StatefulWidget {
|
||||
const _Tab({
|
||||
required this.entry,
|
||||
|
||||
@@ -4,7 +4,13 @@ import 'package:flutter/widgets.dart';
|
||||
import 'kernel_fixture.dart';
|
||||
|
||||
/// Wraps a widget in the minimum tree a primitive needs to resolve
|
||||
/// theme + i18n: `Directionality → ClideKernel → ClideTheme → child`.
|
||||
/// theme + i18n + Overlay (for Draggable feedback / Tooltip / etc.):
|
||||
/// `Directionality → ClideKernel → ClideTheme → MediaQuery →
|
||||
/// Overlay → child`.
|
||||
///
|
||||
/// The Overlay is sized by the test view's bounds via the surrounding
|
||||
/// MediaQuery; no extra SizedBox is added so existing tests that
|
||||
/// query `find.byType(SizedBox).first` still find their target.
|
||||
Widget harness(KernelFixture fixture, Widget child) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
@@ -14,7 +20,11 @@ Widget harness(KernelFixture fixture, Widget child) {
|
||||
controller: fixture.services.theme,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: child,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (_) => child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -151,5 +151,96 @@ void main() {
|
||||
);
|
||||
expect(find.byKey(const ValueKey('body-a')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('drag a tab onto another to reorder', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('c')]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
|
||||
// Drag tab 'a' to where tab 'c' sits.
|
||||
final from = tester.getCenter(find.text('a'));
|
||||
final to = tester.getCenter(find.text('c'));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.moveTo(to);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(c.entries.map((e) => e.id), ['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
testWidgets('drag respects pinned barrier', (tester) async {
|
||||
final c = MultitabController<String>(initial: [
|
||||
entry('p', reorderable: false),
|
||||
entry('a'),
|
||||
entry('b'),
|
||||
]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
|
||||
// Try to drag 'a' before pinned 'p' — controller's barrier
|
||||
// logic should reject and the order stays.
|
||||
final from = tester.getCenter(find.text('a'));
|
||||
final to = tester.getCenter(find.text('p'));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.moveTo(to);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(c.entries.map((e) => e.id), ['p', 'a', 'b']);
|
||||
});
|
||||
|
||||
testWidgets('pinned tabs are not draggable', (tester) async {
|
||||
final c = MultitabController<String>(initial: [
|
||||
entry('p', reorderable: false),
|
||||
entry('a'),
|
||||
]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
|
||||
// Attempt to drag pinned 'p' to position of 'a'.
|
||||
final from = tester.getCenter(find.text('p'));
|
||||
final to = tester.getCenter(find.text('a'));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.moveTo(to);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Order unchanged; pinned tab refused to be dragged.
|
||||
expect(c.entries.map((e) => e.id), ['p', 'a']);
|
||||
});
|
||||
|
||||
testWidgets('allowReorder=false disables drag entirely', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
MultitabPane<String>(
|
||||
controller: c,
|
||||
bodyBuilder: body,
|
||||
allowReorder: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final from = tester.getCenter(find.text('a'));
|
||||
final to = tester.getCenter(find.text('b'));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.moveTo(to);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(c.entries.map((e) => e.id), ['a', 'b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user