From 3550f10dfcf626349f4b1b19e4262b16d000e1a9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 18:44:06 +0200 Subject: [PATCH] add ClideAnchoredOverlay + ClideMenu popover primitive (D-88, T-286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 12 + governance/README.md | 1 + governance/decisions/design.md | 13 + lib/widgets/src/clide_anchored.dart | 292 +++++++++++++++++++ lib/widgets/src/clide_menu.dart | 331 ++++++++++++++++++++++ lib/widgets/widgets.dart | 2 + test/widgets/src/clide_anchored_test.dart | 101 +++++++ test/widgets/src/clide_menu_test.dart | 152 ++++++++++ 9 files changed, 905 insertions(+) create mode 100644 governance/decisions/design.md create mode 100644 lib/widgets/src/clide_anchored.dart create mode 100644 lib/widgets/src/clide_menu.dart create mode 100644 test/widgets/src/clide_anchored_test.dart create mode 100644 test/widgets/src/clide_menu_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 414c84f0..4cd53765 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index c1489022..1e90e7b2 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/governance/README.md b/governance/README.md index ea66e209..e3e2a587 100644 --- a/governance/README.md +++ b/governance/README.md @@ -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 diff --git a/governance/decisions/design.md b/governance/decisions/design.md new file mode 100644 index 00000000..f432afc9 --- /dev/null +++ b/governance/decisions/design.md @@ -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. diff --git a/lib/widgets/src/clide_anchored.dart b/lib/widgets/src/clide_anchored.dart new file mode 100644 index 00000000..a1e39434 --- /dev/null +++ b/lib/widgets/src/clide_anchored.dart @@ -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 createState() => _ClideAnchoredOverlayState(); +} + +class _ClideAnchoredOverlayState extends State { + 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); + } +} diff --git a/lib/widgets/src/clide_menu.dart b/lib/widgets/src/clide_menu.dart new file mode 100644 index 00000000..95bfe512 --- /dev/null +++ b/lib/widgets/src/clide_menu.dart @@ -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 get _navigable { + final out = []; + 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 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 createState() => _ClideMenuState(); +} + +class _ClideMenuState extends State { + 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), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 618cfeb6..7c6fbc91 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/widgets/src/clide_anchored_test.dart b/test/widgets/src/clide_anchored_test.dart new file mode 100644 index 00000000..80a604a5 --- /dev/null +++ b/test/widgets/src/clide_anchored_test.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 present, {bool barrier = true, bool centered = false}) { + return harness( + f, + ValueListenableBuilder( + 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); + }); + }); +} diff --git a/test/widgets/src/clide_menu_test.dart b/test/widgets/src/clide_menu_test.dart new file mode 100644 index 00000000..affd2e0b --- /dev/null +++ b/test/widgets/src/clide_menu_test.dart @@ -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); + }); + }); +}