add ClideAnchoredOverlay + ClideMenu popover primitive (D-88, T-286)
Nine surfaces hand-rolled the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Extract one owned primitive (no Material): - ClideAnchoredOverlay (clide_anchored.dart): positioning + lifecycle — LayerLink/CompositedTransformFollower or centred Positioned, side/align + auto-flip on viewport bounds, full-screen tap-away barrier, OverlayEntry bookkeeping, focus capture, Esc-to-close. Driven by a ClideOverlayController. - ClideMenu + ClideMenuListController (clide_menu.dart): a dropdown-token row surface (items + separators) with arrow/enter/escape nav, skip-disabled, active mark, per-item colour/leading glyph, keepOpenOnSelect (live-apply), and onArrowLeft/Right hooks. The nav controller is reusable by surfaces that keep bespoke rows (typeaheads, quick-open). Additive — no call sites changed yet. D-88 records the convention (new `design` domain): anchored pickers build on these; modal pickers stay on DialogRouter. Tests: clide_anchored_test (open/close, barrier, Esc, centred, clean dispose) and clide_menu_test (list-nav skip/wrap, select + onClose, disabled, Esc, keepOpenOnSelect; pure ClideMenuListController cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -877,3 +877,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-285', 'status', 'in_progress', 'done', NULL, '2026-06-08 15:37:29', '2026-06-08 15:37:29', '2026-06-08 15:37:29', NULL, 'b8289629265f19b4f41318863e744804', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-282', 'status', 'in_progress', 'done', NULL, '2026-06-08 15:42:01', '2026-06-08 15:42:01', '2026-06-08 15:42:01', NULL, '3ebc8c4a480cb16b403e562a4829912a', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'ready', 'in_progress', NULL, '2026-06-08 15:45:13', '2026-06-08 15:45:13', '2026-06-08 15:45:13', NULL, '2745334e049523152af749102abe31e5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'parent_id', 'T-276', 'T-286', NULL, '2026-06-08 16:30:47', '2026-06-08 16:30:47', '2026-06-08 16:30:47', NULL, 'e0d912feca8121cdeb7a6e39b17e5eb0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2272,3 +2272,15 @@ Design to wireframe (frame0-wireframe skill):
|
||||
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
|
||||
|
||||
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 15:45:13', NULL, '9411b3c37dec05c0efb130b120a574de', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-08 16:30:27', NULL, '0da0822536199d472b3c166bf924da0d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'task', 'T-286', 'Frame0 design: permission-mode dropdown beside the Claude composer', 'Produce a Frame0 wireframe for a persistent permission-mode control next to the Claude conversation composer, then use it to drive implementation.
|
||||
|
||||
Goal: surface the current permission mode (default · accept-edits · plan; bypass behind the cockpit guard) AT ALL TIMES, not only in the bottom status bar. Move/duplicate the mode affordance to an icon button sitting to the RIGHT of the composer text box (ClaudeComposer), showing the current mode via icon (+ short label), and opening a dropdown/popover menu to switch mode on click.
|
||||
|
||||
Design to wireframe (frame0-wireframe skill):
|
||||
- Composer row with the mode icon-button anchored at its right edge (trailing), aligned with the send/interrupt control; show resting state for each mode (distinct icon/color per mode) and the open-dropdown state listing selectable modes with the active one marked.
|
||||
- Account for the interaction zone (D-78): when a permission/AskUserQuestion prompt replaces the composer, define what happens to the mode button (hidden? disabled? persists above?).
|
||||
- Keep it consistent with the existing status-bar mode badge (T-226: click or Ctrl/Cmd+M cycles the safe trio) — decide whether the status-bar badge stays, is removed, or mirrors the new control.
|
||||
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
|
||||
|
||||
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 16:30:47', NULL, 'c5616a1e54caf9d9bf91c02f036617c4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -129,6 +129,7 @@ You might also want, project-permitting:
|
||||
- [D-85: Event bus delivery — bounded ring-buffer back-pressure; in-memory cursor retention, bus-owned if persisted](decisions/architecture.md#d-85-event-bus-delivery--bounded-ring-buffer-back-pressure-in-memory-cursor-retention-bus-owned-if-persisted) — _architecture_
|
||||
- [D-86: MCP tool surface — full clide namespace generated from the co-registered command registry](decisions/architecture.md#d-86-mcp-tool-surface--full-clide-namespace-generated-from-the-co-registered-command-registry) — _architecture_
|
||||
- [D-87: Output/log dock — bottom, toggled, read-only (logs + problems)](decisions/architecture.md#d-87-outputlog-dock--bottom-toggled-read-only-logs--problems) — _architecture_
|
||||
- [D-88: clide-owned anchored popover + menu primitive](decisions/design.md#d-88-clide-owned-anchored-popover--menu-primitive) — _design_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Design Decisions
|
||||
|
||||
User-facing surface — UX, UI conventions, and the public shape of clide-owned
|
||||
widget primitives.
|
||||
|
||||
---
|
||||
|
||||
### D-88: clide-owned anchored popover + menu primitive
|
||||
- **Date:** 2026-06-08
|
||||
- **Decision:** Anchored, non-modal popovers (dropdowns, pickers, typeaheads, the command palette) build on two clide-owned primitives in `lib/widgets/`: `ClideAnchoredOverlay` (positioning + lifecycle — a `LayerLink`/`CompositedTransformFollower` or centred `Positioned`, a full-screen tap-away barrier, `OverlayEntry` bookkeeping, focus capture, Esc-to-close, and auto-flip on viewport bounds) and `ClideMenu` + `ClideMenuListController` (a dropdown-token row surface with arrow/enter/escape nav, skip-disabled/separator, active mark, and a reusable nav controller for surfaces that keep bespoke rows). No Material/Cupertino. Modal, centred dialogs (session / project / branch pickers) stay on the kernel `DialogRouter` — a separate concern.
|
||||
- **Rationale:** Nine surfaces had hand-rolled the same anchored-overlay + row-list + barrier + keyboard-nav pattern (menu-bar dropdowns, theme picker, slash + @ typeaheads, quick-open, three modal pickers), each re-deriving positioning, dismissal, and nav — divergent a11y, inconsistent dismissal, and a `pumpAndSettle`-hostile spread of ad-hoc overlays. Owning one primitive (per "own the rendering stack", [D-5](architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer)) makes the behaviour uniform and testable once, and turns the tenth surface (the T-275 permission-mode picker) into a few lines instead of another hand-roll.
|
||||
- **Cost:** A migration sweep across the existing surfaces (menu bar, theme picker, typeaheads, quick-open); the typeaheads keep their text-completion/key pipeline and only delegate anchoring + body, so the primitive must stay composable (a bare lifecycle wrapper + an optional turnkey menu), not a monolith. New UI authors must reach for the primitive rather than rolling another overlay.
|
||||
- **Raised by:** 2026-06-08 — user, while building the T-275 permission-mode picker: "since we don't do Material doesn't mean we can't make components of our own." Realises the "own the rendering stack" guardrail at the component level. Tracked by epic T-286.
|
||||
@@ -0,0 +1,292 @@
|
||||
/// Anchored-overlay positioning + lifecycle primitive (D-88).
|
||||
///
|
||||
/// Every clide popover surface — menu-bar dropdowns, the theme picker, the
|
||||
/// permission-mode picker, the slash / @ typeaheads, quick-open — used to
|
||||
/// re-derive the same four things: a [LayerLink] + [CompositedTransformFollower]
|
||||
/// (or a hand-rolled `Positioned`), a full-screen tap-away barrier, the
|
||||
/// `Overlay.insert` / `OverlayEntry` bookkeeping, and post-frame focus capture.
|
||||
/// This widget owns all of it; callers supply the trigger ([anchor]) and the
|
||||
/// floating content ([overlayBuilder]). Modal, centred dialogs stay on the
|
||||
/// kernel `DialogRouter` — this is for anchored, non-modal popovers.
|
||||
library;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Primary placement of the floating panel relative to the [anchor].
|
||||
enum ClideAnchorSide { below, above, left, right }
|
||||
|
||||
/// Cross-axis alignment of the panel's edge to the anchor's edge.
|
||||
enum ClideAnchorAlign { start, center, end }
|
||||
|
||||
/// Open/close state for a [ClideAnchoredOverlay]. A plain [ChangeNotifier] so
|
||||
/// it composes with `ListenableBuilder` and outside controllers (e.g. the menu
|
||||
/// bar's single-open coordinator drives one of these per top-level button).
|
||||
class ClideOverlayController extends ChangeNotifier {
|
||||
bool _open = false;
|
||||
bool get isOpen => _open;
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggle() => _open ? close() : open();
|
||||
}
|
||||
|
||||
/// Wraps [anchor] with a [CompositedTransformTarget] and, while [controller] is
|
||||
/// open, inserts an [OverlayEntry] built from [overlayBuilder], positioned
|
||||
/// relative to the anchor (or centred when [centered]).
|
||||
class ClideAnchoredOverlay extends StatefulWidget {
|
||||
const ClideAnchoredOverlay({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.anchor,
|
||||
required this.overlayBuilder,
|
||||
this.side = ClideAnchorSide.below,
|
||||
this.align = ClideAnchorAlign.start,
|
||||
this.offset = const Offset(0, 2),
|
||||
this.autoFlip = true,
|
||||
this.barrier = true,
|
||||
this.dismissOnEscape = true,
|
||||
this.captureFocus = true,
|
||||
this.rootOverlay = false,
|
||||
this.centered = false,
|
||||
this.onOpened,
|
||||
this.onClosed,
|
||||
});
|
||||
|
||||
final ClideOverlayController controller;
|
||||
|
||||
/// The trigger widget. Wrapped in a [CompositedTransformTarget].
|
||||
final Widget anchor;
|
||||
|
||||
/// Builds the floating content. Receives the [controller] so rows can close
|
||||
/// the overlay on activation.
|
||||
final Widget Function(BuildContext context, ClideOverlayController controller) overlayBuilder;
|
||||
|
||||
final ClideAnchorSide side;
|
||||
final ClideAnchorAlign align;
|
||||
|
||||
/// Follower offset for the requested [side]. On an [autoFlip] vertical flip
|
||||
/// the dy is negated so the gap stays on the correct edge.
|
||||
final Offset offset;
|
||||
|
||||
/// Flip [side] to its opposite when the anchor sits in the far edge of the
|
||||
/// viewport (e.g. a status-bar control at the window bottom opens upward).
|
||||
final bool autoFlip;
|
||||
|
||||
/// Insert a full-screen tap-away barrier behind the panel.
|
||||
final bool barrier;
|
||||
|
||||
/// Close on Escape (as a fallback; menu content may handle Esc first).
|
||||
final bool dismissOnEscape;
|
||||
|
||||
/// Request focus into the panel on open.
|
||||
final bool captureFocus;
|
||||
|
||||
/// Insert into the root overlay (menu bar needs this to clear pane chrome).
|
||||
final bool rootOverlay;
|
||||
|
||||
/// Ignore the anchor; render the panel centred horizontally at [offset].dy
|
||||
/// from the top (the command-palette / quick-open shape).
|
||||
final bool centered;
|
||||
|
||||
final VoidCallback? onOpened;
|
||||
final VoidCallback? onClosed;
|
||||
|
||||
@override
|
||||
State<ClideAnchoredOverlay> createState() => _ClideAnchoredOverlayState();
|
||||
}
|
||||
|
||||
class _ClideAnchoredOverlayState extends State<ClideAnchoredOverlay> {
|
||||
final LayerLink _link = LayerLink();
|
||||
final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'clide-anchored');
|
||||
OverlayEntry? _entry;
|
||||
ClideAnchorSide _resolvedSide = ClideAnchorSide.below;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolvedSide = widget.side;
|
||||
widget.controller.addListener(_sync);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClideAnchoredOverlay old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!identical(old.controller, widget.controller)) {
|
||||
old.controller.removeListener(_sync);
|
||||
widget.controller.addListener(_sync);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_sync);
|
||||
_entry?.remove();
|
||||
_entry = null;
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
if (widget.controller.isOpen && _entry == null) {
|
||||
_resolvedSide = _computeSide();
|
||||
_entry = OverlayEntry(builder: _buildEntry);
|
||||
Overlay.of(context, rootOverlay: widget.rootOverlay).insert(_entry!);
|
||||
if (widget.captureFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _entry != null) _scope.requestFocus();
|
||||
});
|
||||
}
|
||||
widget.onOpened?.call();
|
||||
} else if (!widget.controller.isOpen && _entry != null) {
|
||||
_entry!.remove();
|
||||
_entry = null;
|
||||
widget.onClosed?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve [ClideAnchorSide] honouring [autoFlip]: a vertical side flips when
|
||||
/// the anchor is past 60% (below) / before 40% (above) of the viewport.
|
||||
ClideAnchorSide _computeSide() {
|
||||
var side = widget.side;
|
||||
if (!widget.autoFlip || widget.centered) return side;
|
||||
final box = context.findRenderObject();
|
||||
final media = MediaQuery.maybeOf(context);
|
||||
if (box is RenderBox && box.hasSize && media != null) {
|
||||
final rect = box.localToGlobal(Offset.zero) & box.size;
|
||||
final h = media.size.height;
|
||||
if (side == ClideAnchorSide.below && rect.bottom > h * 0.6) {
|
||||
side = ClideAnchorSide.above;
|
||||
} else if (side == ClideAnchorSide.above && rect.top < h * 0.4) {
|
||||
side = ClideAnchorSide.below;
|
||||
}
|
||||
}
|
||||
return side;
|
||||
}
|
||||
|
||||
(Alignment target, Alignment follower) _alignments(ClideAnchorSide side) {
|
||||
final a = widget.align;
|
||||
switch (side) {
|
||||
case ClideAnchorSide.below:
|
||||
return (
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.bottomLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomRight
|
||||
: Alignment.bottomCenter,
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.topRight
|
||||
: Alignment.topCenter,
|
||||
);
|
||||
case ClideAnchorSide.above:
|
||||
return (
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.topRight
|
||||
: Alignment.topCenter,
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.bottomLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomRight
|
||||
: Alignment.bottomCenter,
|
||||
);
|
||||
case ClideAnchorSide.right:
|
||||
return (
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topRight
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomRight
|
||||
: Alignment.centerRight,
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.centerLeft,
|
||||
);
|
||||
case ClideAnchorSide.left:
|
||||
return (
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topLeft
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.centerLeft,
|
||||
a == ClideAnchorAlign.start
|
||||
? Alignment.topRight
|
||||
: a == ClideAnchorAlign.end
|
||||
? Alignment.bottomRight
|
||||
: Alignment.centerRight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEntry(BuildContext context) {
|
||||
// Content owns its own focus (ClideMenu autofocuses + handles keys). The
|
||||
// FocusScope isolates Tab traversal from the page behind; the Escape Focus
|
||||
// sits ABOVE the scope so it catches Esc the content left unhandled, without
|
||||
// stealing focus from the content.
|
||||
Widget content = widget.overlayBuilder(context, widget.controller);
|
||||
if (widget.captureFocus) content = FocusScope(node: _scope, child: content);
|
||||
if (widget.dismissOnEscape) {
|
||||
content = Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.controller.close();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
final Widget positioned;
|
||||
if (widget.centered) {
|
||||
positioned = Positioned(
|
||||
top: widget.offset.dy,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(child: content),
|
||||
);
|
||||
} else {
|
||||
final flipped = _resolvedSide != widget.side;
|
||||
final off = flipped ? Offset(widget.offset.dx, -widget.offset.dy) : widget.offset;
|
||||
final (target, follower) = _alignments(_resolvedSide);
|
||||
positioned = CompositedTransformFollower(
|
||||
link: _link,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: target,
|
||||
followerAnchor: follower,
|
||||
offset: off,
|
||||
child: Align(alignment: follower, child: content),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (widget.barrier)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: widget.controller.close),
|
||||
),
|
||||
positioned,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformTarget(link: _link, child: widget.anchor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/// Menu content + reusable list-nav for clide popovers (D-88).
|
||||
///
|
||||
/// [ClideMenu] is the turnkey content for a [ClideAnchoredOverlay]: a
|
||||
/// `dropdown`-token surface of selectable rows + separators, with arrow / enter
|
||||
/// / escape navigation, optional mouse-hover highlight, an active mark, and
|
||||
/// disabled rows. [ClideMenuListController] factors the skip-disabled / wrap
|
||||
/// highlight logic so surfaces that keep bespoke rows (the typeaheads,
|
||||
/// quick-open) reuse identical key handling without [ClideMenu]'s rendering.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/icons/check.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Tracks the highlighted row across a navigable list, skipping rows for which
|
||||
/// [isSelectable] is false (separators, disabled items). [length] is mutable so
|
||||
/// live-filtered lists (typeaheads, quick-open) can resize without rebuilding.
|
||||
class ClideMenuListController extends ChangeNotifier {
|
||||
ClideMenuListController({required this.isSelectable, required int length, this.wrap = true}) : _length = length;
|
||||
|
||||
final bool Function(int index) isSelectable;
|
||||
final bool wrap;
|
||||
|
||||
int _length;
|
||||
int get length => _length;
|
||||
set length(int value) {
|
||||
if (value == _length) return;
|
||||
_length = value;
|
||||
if (_highlight >= value) _highlight = -1;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _highlight = -1;
|
||||
int get highlighted => _highlight;
|
||||
|
||||
void setHighlight(int index) {
|
||||
if (index == _highlight) return;
|
||||
_highlight = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() => setHighlight(-1);
|
||||
|
||||
void moveNext() => _move(1);
|
||||
void movePrev() => _move(-1);
|
||||
|
||||
List<int> get _navigable {
|
||||
final out = <int>[];
|
||||
for (var i = 0; i < _length; i++) {
|
||||
if (isSelectable(i)) out.add(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void _move(int dir) {
|
||||
final nav = _navigable;
|
||||
if (nav.isEmpty) return;
|
||||
final pos = nav.indexOf(_highlight);
|
||||
int next;
|
||||
if (pos < 0) {
|
||||
next = dir > 0 ? 0 : nav.length - 1;
|
||||
} else {
|
||||
next = pos + dir;
|
||||
if (next < 0 || next >= nav.length) {
|
||||
if (!wrap) return;
|
||||
next = (next + nav.length) % nav.length;
|
||||
}
|
||||
}
|
||||
setHighlight(nav[next]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row or separator in a [ClideMenu].
|
||||
sealed class ClideMenuEntry {
|
||||
const ClideMenuEntry();
|
||||
}
|
||||
|
||||
/// A selectable menu row.
|
||||
class ClideMenuItem extends ClideMenuEntry {
|
||||
const ClideMenuItem({
|
||||
required this.label,
|
||||
required this.onSelect,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.active = false,
|
||||
this.enabled = true,
|
||||
this.color,
|
||||
this.keepOpenOnSelect = false,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Leading glyph (per-mode icon, active check substitute, etc.).
|
||||
final ClideIconPainter? leading;
|
||||
final String label;
|
||||
|
||||
/// Trailing widget (a keybinding label, say). When null and [active] is set,
|
||||
/// a check mark is drawn instead.
|
||||
final Widget? trailing;
|
||||
|
||||
/// Marks the current selection (check mark + accent).
|
||||
final bool active;
|
||||
final bool enabled;
|
||||
|
||||
/// Foreground tint for the row (per-mode colour). Defaults to dropdown fg.
|
||||
final Color? color;
|
||||
|
||||
/// Keep the overlay open after selecting (e.g. a live-apply toggle).
|
||||
final bool keepOpenOnSelect;
|
||||
|
||||
final VoidCallback onSelect;
|
||||
|
||||
/// Overrides [label] for screen readers (e.g. proper-case vs lowercase).
|
||||
final String? semanticLabel;
|
||||
}
|
||||
|
||||
/// A hairline divider between groups of items.
|
||||
class ClideMenuSeparator extends ClideMenuEntry {
|
||||
const ClideMenuSeparator();
|
||||
}
|
||||
|
||||
/// The turnkey popover content: renders [entries] on a dropdown-token surface
|
||||
/// with keyboard + mouse navigation. Call [onClose] is invoked after a normal
|
||||
/// (non-[ClideMenuItem.keepOpenOnSelect]) selection and on Escape.
|
||||
class ClideMenu extends StatefulWidget {
|
||||
const ClideMenu({
|
||||
super.key,
|
||||
required this.entries,
|
||||
this.onClose,
|
||||
this.controller,
|
||||
this.minWidth = 220,
|
||||
this.maxWidth = 420,
|
||||
this.maxHeight,
|
||||
this.hoverHighlight = true,
|
||||
this.onArrowLeft,
|
||||
this.onArrowRight,
|
||||
this.autofocus = true,
|
||||
});
|
||||
|
||||
final List<ClideMenuEntry> entries;
|
||||
|
||||
/// Closes the host overlay. Called on normal select + Escape.
|
||||
final VoidCallback? onClose;
|
||||
|
||||
/// Externally-owned nav controller. When null the menu creates its own.
|
||||
final ClideMenuListController? controller;
|
||||
|
||||
final double minWidth;
|
||||
final double maxWidth;
|
||||
final double? maxHeight;
|
||||
|
||||
/// Whether mouse hover moves the keyboard highlight (typeaheads disable this).
|
||||
final bool hoverHighlight;
|
||||
|
||||
/// Optional left/right hooks (the menu bar switches top menus).
|
||||
final VoidCallback? onArrowLeft;
|
||||
final VoidCallback? onArrowRight;
|
||||
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
State<ClideMenu> createState() => _ClideMenuState();
|
||||
}
|
||||
|
||||
class _ClideMenuState extends State<ClideMenu> {
|
||||
final FocusNode _focus = FocusNode(debugLabel: 'clide-menu');
|
||||
late ClideMenuListController _ctrl;
|
||||
bool _ownsController = false;
|
||||
|
||||
bool _selectable(int i) => widget.entries[i] is ClideMenuItem && (widget.entries[i] as ClideMenuItem).enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = widget.controller ?? _makeController();
|
||||
_ownsController = widget.controller == null;
|
||||
_ctrl.addListener(_onCtrl);
|
||||
// Grab focus once mounted so arrow/enter/esc land here even inside a freshly
|
||||
// inserted overlay (autofocus alone is unreliable across overlay boundaries).
|
||||
if (widget.autofocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _focus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ClideMenuListController _makeController() => ClideMenuListController(isSelectable: _selectable, length: widget.entries.length);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClideMenu old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (_ownsController && old.entries.length != widget.entries.length) {
|
||||
_ctrl.length = widget.entries.length;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCtrl() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.removeListener(_onCtrl);
|
||||
if (_ownsController) _ctrl.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _activate(int index) {
|
||||
final entry = widget.entries[index];
|
||||
if (entry is! ClideMenuItem || !entry.enabled) return;
|
||||
entry.onSelect();
|
||||
if (!entry.keepOpenOnSelect) widget.onClose?.call();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
switch (event.logicalKey) {
|
||||
case LogicalKeyboardKey.arrowDown:
|
||||
_ctrl.moveNext();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.arrowUp:
|
||||
_ctrl.movePrev();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.enter:
|
||||
case LogicalKeyboardKey.numpadEnter:
|
||||
case LogicalKeyboardKey.space:
|
||||
if (_ctrl.highlighted >= 0) _activate(_ctrl.highlighted);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.escape:
|
||||
widget.onClose?.call();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.arrowLeft:
|
||||
if (widget.onArrowLeft != null) {
|
||||
widget.onArrowLeft!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
case LogicalKeyboardKey.arrowRight:
|
||||
if (widget.onArrowRight != null) {
|
||||
widget.onArrowRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final col = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < widget.entries.length; i++) _row(i, widget.entries[i], t),
|
||||
],
|
||||
);
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: _onKey,
|
||||
child: IntrinsicWidth(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: widget.minWidth,
|
||||
maxWidth: widget.maxWidth,
|
||||
maxHeight: widget.maxHeight ?? double.infinity,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: t.dropdownBackground,
|
||||
border: Border.all(color: t.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [BoxShadow(color: t.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: widget.maxHeight != null ? SingleChildScrollView(child: col) : col,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(int index, ClideMenuEntry entry, SurfaceTokens t) {
|
||||
return switch (entry) {
|
||||
ClideMenuSeparator() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Container(height: 1, color: t.dividerColor),
|
||||
),
|
||||
ClideMenuItem() => _itemRow(index, entry, t),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _itemRow(int index, ClideMenuItem item, SurfaceTokens t) {
|
||||
final highlighted = _ctrl.highlighted == index;
|
||||
final fg = item.enabled ? (item.color ?? t.dropdownForeground) : t.globalTextMuted;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: item.enabled,
|
||||
selected: item.active,
|
||||
label: item.semanticLabel ?? item.label,
|
||||
excludeSemantics: true,
|
||||
child: MouseRegion(
|
||||
onEnter: widget.hoverHighlight && item.enabled ? (_) => _ctrl.setHighlight(index) : null,
|
||||
child: ClideTappable(
|
||||
onTap: item.enabled ? () => _activate(index) : null,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
color: highlighted || (hovered && item.enabled) ? t.listItemHoverBackground : null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (item.leading != null) ...[
|
||||
ClideIcon(item.leading!, size: 14, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: ClideText(item.label, fontSize: clideFontSmall, color: fg, maxLines: 1)),
|
||||
if (item.trailing != null)
|
||||
item.trailing!
|
||||
else if (item.active) ...[
|
||||
const SizedBox(width: 8),
|
||||
ClideIcon(const CheckIcon(), size: 12, color: item.color ?? t.globalFocus),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
library;
|
||||
|
||||
export 'src/clide_accordion.dart';
|
||||
export 'src/clide_anchored.dart';
|
||||
export 'src/clide_button.dart';
|
||||
export 'src/clide_column_hat.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
@@ -14,6 +15,7 @@ export 'src/clide_filter_box.dart';
|
||||
export 'src/clide_lightbox.dart';
|
||||
export 'src/clide_markdown.dart';
|
||||
export 'src/clide_marquee.dart';
|
||||
export 'src/clide_menu.dart';
|
||||
export 'src/clide_svg_view.dart';
|
||||
export 'src/clide_toast.dart';
|
||||
export 'src/clide_icon.dart';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// Tests for ClideAnchoredOverlay (D-88): controller-driven insert/remove,
|
||||
/// barrier + Escape dismissal, centered mode, and clean dispose.
|
||||
library;
|
||||
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
group('ClideAnchoredOverlay', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
Widget host(ClideOverlayController c, ValueNotifier<bool> present, {bool barrier = true, bool centered = false}) {
|
||||
return harness(
|
||||
f,
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: present,
|
||||
builder: (_, show, __) => show
|
||||
? Align(
|
||||
alignment: Alignment.center,
|
||||
child: ClideAnchoredOverlay(
|
||||
controller: c,
|
||||
barrier: barrier,
|
||||
centered: centered,
|
||||
autoFlip: false,
|
||||
anchor: const SizedBox(width: 60, height: 24, child: ClideText('anchor')),
|
||||
overlayBuilder: (_, __) => const SizedBox(width: 120, height: 60, child: ClideText('panel')),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('open inserts the panel; close removes it', (tester) async {
|
||||
final c = ClideOverlayController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(host(c, ValueNotifier(true)));
|
||||
expect(find.text('panel'), findsNothing);
|
||||
c.open();
|
||||
await tester.pump();
|
||||
expect(find.text('panel'), findsOneWidget);
|
||||
c.close();
|
||||
await tester.pump();
|
||||
expect(find.text('panel'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('barrier tap closes', (tester) async {
|
||||
final c = ClideOverlayController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(host(c, ValueNotifier(true)));
|
||||
c.open();
|
||||
await tester.pump();
|
||||
await tester.tapAt(const Offset(2, 2));
|
||||
await tester.pump();
|
||||
expect(c.isOpen, isFalse);
|
||||
expect(find.text('panel'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Escape closes', (tester) async {
|
||||
final c = ClideOverlayController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(host(c, ValueNotifier(true)));
|
||||
c.open();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(c.isOpen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('centered mode renders the panel', (tester) async {
|
||||
final c = ClideOverlayController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(host(c, ValueNotifier(true), centered: true));
|
||||
c.open();
|
||||
await tester.pump();
|
||||
expect(find.text('panel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unmounting the host while open removes the entry without crashing', (tester) async {
|
||||
final c = ClideOverlayController();
|
||||
addTearDown(c.dispose);
|
||||
final present = ValueNotifier(true);
|
||||
await tester.pumpWidget(host(c, present));
|
||||
c.open();
|
||||
await tester.pump();
|
||||
expect(find.text('panel'), findsOneWidget);
|
||||
present.value = false; // unmount ClideAnchoredOverlay -> dispose removes entry
|
||||
await tester.pump(); // rebuild host (unmount + dispose)
|
||||
await tester.pump(); // overlay rebuild reflects the removed entry
|
||||
expect(find.text('panel'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/// Tests for ClideMenu + ClideMenuListController (D-88): list-nav skipping
|
||||
/// disabled/separators, select fires onSelect+onClose, keepOpenOnSelect, Esc.
|
||||
library;
|
||||
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
group('ClideMenuListController', () {
|
||||
test('moveNext/movePrev skip non-selectable and wrap', () {
|
||||
const sel = {0, 3, 4}; // 1=separator, 2=disabled
|
||||
final c = ClideMenuListController(isSelectable: (i) => sel.contains(i), length: 5);
|
||||
expect(c.highlighted, -1);
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 0);
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 3);
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 4);
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 0, reason: 'wraps to first selectable');
|
||||
c.movePrev();
|
||||
expect(c.highlighted, 4, reason: 'wraps back to last selectable');
|
||||
});
|
||||
|
||||
test('movePrev from none selects the last selectable', () {
|
||||
final c = ClideMenuListController(isSelectable: (_) => true, length: 3);
|
||||
c.movePrev();
|
||||
expect(c.highlighted, 2);
|
||||
});
|
||||
|
||||
test('wrap:false stops at the ends', () {
|
||||
final c = ClideMenuListController(isSelectable: (_) => true, length: 3, wrap: false);
|
||||
c.moveNext();
|
||||
c.moveNext();
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 2);
|
||||
c.moveNext();
|
||||
expect(c.highlighted, 2, reason: 'no wrap');
|
||||
});
|
||||
|
||||
test('length resize clears a now-out-of-range highlight', () {
|
||||
final c = ClideMenuListController(isSelectable: (_) => true, length: 5);
|
||||
c.setHighlight(4);
|
||||
c.length = 3;
|
||||
expect(c.highlighted, -1);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClideMenu', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
testWidgets('renders items + separator; tapping an item fires onSelect + onClose', (tester) async {
|
||||
var picked = '';
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClideMenu(
|
||||
onClose: () => closed++,
|
||||
entries: [
|
||||
ClideMenuItem(label: 'Alpha', onSelect: () => picked = 'Alpha'),
|
||||
const ClideMenuSeparator(),
|
||||
ClideMenuItem(label: 'Beta', onSelect: () => picked = 'Beta'),
|
||||
],
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(find.text('Alpha'), findsOneWidget);
|
||||
expect(find.text('Beta'), findsOneWidget);
|
||||
await tester.tap(find.text('Beta'));
|
||||
await tester.pump();
|
||||
expect(picked, 'Beta');
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testWidgets('arrow-down then Enter activates the highlighted item', (tester) async {
|
||||
var picked = '';
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClideMenu(
|
||||
onClose: () {},
|
||||
entries: [
|
||||
ClideMenuItem(label: 'One', onSelect: () => picked = 'One'),
|
||||
ClideMenuItem(label: 'Two', onSelect: () => picked = 'Two'),
|
||||
],
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(picked, 'Two');
|
||||
});
|
||||
|
||||
testWidgets('disabled item is inert and skipped by arrows', (tester) async {
|
||||
var picked = '';
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClideMenu(
|
||||
onClose: () {},
|
||||
entries: [
|
||||
ClideMenuItem(label: 'Able', onSelect: () => picked = 'Able'),
|
||||
ClideMenuItem(label: 'Off', enabled: false, onSelect: () => picked = 'Off'),
|
||||
ClideMenuItem(label: 'Last', onSelect: () => picked = 'Last'),
|
||||
],
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Off'));
|
||||
await tester.pump();
|
||||
expect(picked, '', reason: 'disabled tap is a no-op');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // -> Able
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // skip Off -> Last
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(picked, 'Last');
|
||||
});
|
||||
|
||||
testWidgets('Escape calls onClose', (tester) async {
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(harness(f, ClideMenu(onClose: () => closed++, entries: [ClideMenuItem(label: 'X', onSelect: () {})])));
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testWidgets('keepOpenOnSelect item fires onSelect but not onClose', (tester) async {
|
||||
var picked = 0;
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClideMenu(
|
||||
onClose: () => closed++,
|
||||
entries: [ClideMenuItem(label: 'Toggle', keepOpenOnSelect: true, onSelect: () => picked++)],
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Toggle'));
|
||||
await tester.pump();
|
||||
expect(picked, 1);
|
||||
expect(closed, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user