// widgets/ds/ds_menu.dart — 原型 .menu/.menu-item 下拉菜单(镜像 atoms.css + shell.js openMenu)。 // 定位规则照抄 openMenu:宽 = max(锚宽, minW=168),左缘夹在视口内(留 8px), // 锚下 6px 展开、下方放不下且上方够高则向上翻。 import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/theme/app_tokens.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; /// 菜单项:sel → 前导 ✓(primary) + 文字 primary fw600;icon → 前导 16px 图标; /// 都没有 → 16px 占位(对齐 openMenu 的 lead 槽)。 class DsMenuItem { final T value; final String label; final IconData? icon; final bool selected; const DsMenuItem({ required this.value, required this.label, this.icon, this.selected = false, }); } /// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。 /// /// [searchHint] 非空 → 升级为「可搜索单选」下拉(镜像原型 openSearchMenu / .combo-pop, /// 照 stock-out 客户筛选写法):顶部持久搜索框 + 边打边过滤 + 命中高亮 + 底部结果统计, /// 点选即返回并关闭。宽度锚定触发器宽(`max(触发器宽, 220)` 固定,对齐原型 openSearchMenu /// `w = max(r.width, 220)`)、列表 264 封顶,供规格/系列等长名录单选筛选复用。 Future showDsMenu( BuildContext anchorContext, { required List> items, double minWidth = 168, String? searchHint, }) { final rect = _anchorRect(anchorContext); return Navigator.of(anchorContext).push(_DsMenuRoute( anchorRect: rect, minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth, search: searchHint != null, builder: (ctx) => searchHint != null ? _DsSearchSinglePanel( items: items, hint: searchHint, onPick: (v) => Navigator.of(ctx).pop(v), ) : _DsMenuPanel( children: [ for (final it in items) _DsMenuItemTile( item: it, onTap: () => Navigator.of(ctx).pop(it.value), ), ], ), )); } /// 多选菜单(列设置):点击项切换选中、菜单保持打开;点外部关闭。 /// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。 Future showDsMultiMenu( BuildContext anchorContext, { required List> Function() itemsBuilder, required ValueChanged onToggle, double minWidth = 168, }) { final rect = _anchorRect(anchorContext); return Navigator.of(anchorContext).push(_DsMenuRoute( anchorRect: rect, minWidth: minWidth, builder: (ctx) => StatefulBuilder( builder: (ctx, setState) => _DsMenuPanel( children: [ for (final it in itemsBuilder()) _DsMenuItemTile( item: it, onTap: () { onToggle(it.value); setState(() {}); }, ), ], ), ), )); } Rect _anchorRect(BuildContext anchorContext) { // 锚点矩形必须和菜单路由同坐标系:路由推到 Navigator.of(anchorContext), // 该导航器不一定占满屏幕(shell 分支导航器原点在侧栏右/顶栏下; // useRootNavigator:false 的抽屉同理),用全局坐标会整体漂移。 final box = anchorContext.findRenderObject() as RenderBox; final navBox = Navigator.of(anchorContext).context.findRenderObject() as RenderBox?; final origin = box.localToGlobal(Offset.zero, ancestor: navBox); return origin & box.size; } class _DsMenuRoute extends PopupRoute { final Rect anchorRect; final double minWidth; final bool search; final WidgetBuilder builder; _DsMenuRoute({ required this.anchorRect, required this.minWidth, this.search = false, required this.builder, }); @override Color? get barrierColor => Colors.transparent; @override bool get barrierDismissible => true; @override String? get barrierLabel => 'menu'; @override Duration get transitionDuration => Duration.zero; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return CustomSingleChildLayout( delegate: _DsMenuLayout(anchorRect, minWidth, search), child: builder(context), ); } } class _DsMenuLayout extends SingleChildLayoutDelegate { final Rect anchor; final double minWidth; final bool search; _DsMenuLayout(this.anchor, this.minWidth, this.search); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { final avail = constraints.maxWidth - 16; final maxH = math.max(0.0, constraints.maxHeight - 16); if (search) { // 可搜索下拉:宽度锚定触发器宽(对齐原型 openSearchMenu `w = max(r.width, 220)`), // tight 固定,避免 Expanded 搜索框撑满视口。触发器宽 → 弹层宽;窄 pill → 220。 final w = math.min(math.max(anchor.width, minWidth), avail); return BoxConstraints(minWidth: w, maxWidth: w, maxHeight: maxH); } return BoxConstraints( minWidth: math.min(math.max(anchor.width, minWidth), avail), maxWidth: avail, maxHeight: maxH, ); } @override Offset getPositionForChild(Size size, Size childSize) { final left = anchor.left .clamp(8.0, math.max(8.0, size.width - childSize.width - 8)) .toDouble(); final below = anchor.bottom + 6; final above = anchor.top - childSize.height - 6; final top = (below + childSize.height > size.height && above > 6) ? above : below; return Offset(left, top); } @override bool shouldRelayout(_DsMenuLayout old) => old.anchor != anchor || old.minWidth != minWidth || old.search != search; } /// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。 class _DsMenuPanel extends StatelessWidget { final List children; const _DsMenuPanel({required this.children}); @override Widget build(BuildContext context) { final t = context.tokens; return Material( color: Colors.transparent, child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), boxShadow: [ BoxShadow( color: t.shadow, offset: const Offset(0, 4), blurRadius: 14), ], ), child: SingleChildScrollView( child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), ), ), ), ); } } /// .menu-item:gap9 / pad 8 10 / r-sm / fs-body,hover→bg; /// sel → 前导 ✓ + primary fw600;icon 颜色随文字(.mi-ic 继承 currentColor)。 class _DsMenuItemTile extends StatelessWidget { final DsMenuItem item; final VoidCallback onTap; const _DsMenuItemTile({required this.item, required this.onTap}); @override Widget build(BuildContext context) { final t = context.tokens; final fg = item.selected ? t.primary : t.text; return InkWell( onTap: onTap, hoverColor: t.bg, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 16, height: 16, child: item.selected ? Icon(LucideIcons.check, size: 16, color: t.primary) : (item.icon != null ? Icon(item.icon, size: 16, color: fg) : null), ), const SizedBox(width: 9), Text(item.label, style: TextStyle( fontSize: AppDims.fsBody, color: fg, fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400)), ], ), ), ); } } /// 可搜索单选面板(镜像原型 .combo-pop + openSearchMenu,照 stock-out 客户筛选): /// 顶部 .cp-search 持久搜索框 → .cp-list 列表(命中子串高亮、选中项尾部 ✓、点选即返回并关闭) /// → .cp-foot 结果统计(匹配 X 条 · 共 N 条)。供工具栏规格/系列等长名录单选筛选复用。 class _DsSearchSinglePanel extends StatefulWidget { final List> items; final ValueChanged onPick; final String hint; const _DsSearchSinglePanel({ required this.items, required this.onPick, required this.hint, }); @override State<_DsSearchSinglePanel> createState() => _DsSearchSinglePanelState(); } class _DsSearchSinglePanelState extends State<_DsSearchSinglePanel> { final _ctrl = TextEditingController(); final _focus = FocusNode(); String _kw = ''; @override void initState() { super.initState(); WidgetsBinding.instance .addPostFrameCallback((_) => _focus.requestFocus()); } @override void dispose() { _ctrl.dispose(); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final t = context.tokens; final all = widget.items; final q = _kw.trim().toLowerCase(); final hits = q.isEmpty ? all : all.where((it) => it.label.toLowerCase().contains(q)).toList(); return Material( color: Colors.transparent, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), boxShadow: [ BoxShadow( color: t.shadow, offset: const Offset(0, 4), blurRadius: 14), ], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // .cp-search Container( padding: const EdgeInsets.fromLTRB(11, 9, 11, 9), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle)), ), child: Row( children: [ Icon(LucideIcons.search, size: 14, color: t.faint), const SizedBox(width: 8), Expanded( child: TextField( controller: _ctrl, focusNode: _focus, style: TextStyle(fontSize: AppDims.fsBody, color: t.text), cursorColor: t.primary, onChanged: (v) => setState(() => _kw = v), decoration: InputDecoration( isCollapsed: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: widget.hint, hintStyle: TextStyle( fontSize: AppDims.fsBody, color: t.faint), ), ), ), ], ), ), // .cp-list(overflow auto,max-height:264 → 超出滚动、foot 常驻可见;空 → .cp-empty) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 264), child: hits.isEmpty ? Padding( padding: const EdgeInsets.all(20), child: Text('无匹配结果', textAlign: TextAlign.center, style: TextStyle( fontSize: AppDims.fsSm, color: t.muted)), ) : ListView.builder( padding: const EdgeInsets.all(5), shrinkWrap: true, itemCount: hits.length, itemBuilder: (c, i) => _item(t, hits[i]), ), ), // .cp-foot Container( padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), decoration: BoxDecoration( color: t.bg, border: Border(top: BorderSide(color: t.borderSubtle)), ), child: _foot(t, hits.length, all.length, q.isNotEmpty), ), ], ), ), ); } // .cp-item:点选即回调 onPick(其内 Navigator.pop 关闭菜单)→ 单选。 Widget _item(AppTokens t, DsMenuItem it) { final fg = it.selected ? t.primary : t.text; return InkWell( onTap: () => widget.onPick(it.value), hoverColor: t.rowHover, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ Expanded(child: _highlight(t, it.label, fg, it.selected)), if (it.selected) ...[ const SizedBox(width: 9), Icon(LucideIcons.check, size: 15, color: t.primary), ], ], ), ), ); } // 命中子串高亮(.hl:primary fw700) Widget _highlight(AppTokens t, String name, Color fg, bool sel) { final base = TextStyle( fontSize: AppDims.fsBody, color: fg, fontWeight: sel ? FontWeight.w600 : FontWeight.w400); final kw = _kw.trim(); final i = kw.isEmpty ? -1 : name.toLowerCase().indexOf(kw.toLowerCase()); if (i < 0) { return Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: base); } return RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: TextSpan(style: base, children: [ TextSpan(text: name.substring(0, i)), TextSpan( text: name.substring(i, i + kw.length), style: base.copyWith( color: t.primary, fontWeight: FontWeight.w700)), TextSpan(text: name.substring(i + kw.length)), ]), ); } // .cp-foot:q 空 → 共 N 条;有关键字 → 匹配 X 条 · 共 N 条。数字 mono 加粗。 Widget _foot(AppTokens t, int hits, int total, bool filtered) { final base = TextStyle(fontSize: AppDims.fsXs, color: t.muted); final b = base.copyWith( color: t.text, fontWeight: FontWeight.w600, fontFamily: 'JetBrainsMono'); return RichText( text: TextSpan(style: base, children: [ if (filtered) ...[ const TextSpan(text: '匹配 '), TextSpan(text: '$hits', style: b), const TextSpan(text: ' 条 · 共 '), TextSpan(text: '$total', style: b), const TextSpan(text: ' 条'), ] else ...[ const TextSpan(text: '共 '), TextSpan(text: '$total', style: b), const TextSpan(text: ' 条'), ], ]), ); } }