801b8528ec
原型与代码同提交(design-first):下拉宽度锚定触发器宽 max(宽,220)、列表 264 封顶超出滚动、底部计数常驻。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
453 lines
15 KiB
Dart
453 lines
15 KiB
Dart
// 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<T> {
|
||
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<T?> showDsMenu<T>(
|
||
BuildContext anchorContext, {
|
||
required List<DsMenuItem<T>> items,
|
||
double minWidth = 168,
|
||
String? searchHint,
|
||
}) {
|
||
final rect = _anchorRect(anchorContext);
|
||
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
|
||
anchorRect: rect,
|
||
minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth,
|
||
search: searchHint != null,
|
||
builder: (ctx) => searchHint != null
|
||
? _DsSearchSinglePanel<T>(
|
||
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<void> showDsMultiMenu<T>(
|
||
BuildContext anchorContext, {
|
||
required List<DsMenuItem<T>> Function() itemsBuilder,
|
||
required ValueChanged<T> onToggle,
|
||
double minWidth = 168,
|
||
}) {
|
||
final rect = _anchorRect(anchorContext);
|
||
return Navigator.of(anchorContext).push<void>(_DsMenuRoute<void>(
|
||
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<T> extends PopupRoute<T> {
|
||
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<double> animation,
|
||
Animation<double> 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<Widget> 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<dynamic> 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<T> extends StatefulWidget {
|
||
final List<DsMenuItem<T>> items;
|
||
final ValueChanged<T> onPick;
|
||
final String hint;
|
||
const _DsSearchSinglePanel({
|
||
required this.items,
|
||
required this.onPick,
|
||
required this.hint,
|
||
});
|
||
|
||
@override
|
||
State<_DsSearchSinglePanel<T>> createState() =>
|
||
_DsSearchSinglePanelState<T>();
|
||
}
|
||
|
||
class _DsSearchSinglePanelState<T> extends State<_DsSearchSinglePanel<T>> {
|
||
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<T> 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: ' 条'),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
}
|