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:
2026-06-08 18:44:06 +02:00
co-authored by Claude Opus 4.8
parent 628db30645
commit 3550f10dfc
9 changed files with 905 additions and 0 deletions
+292
View File
@@ -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);
}
}
+331
View File
@@ -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),
],
],
),
),
),
),
);
}
}