Popover
ui.widgets.PopoverContenido transitorio más rico que un tooltip.
BEHAVIOR CONTRACT
Los disparadores click / hover, el posicionamiento, el clic fuera, Escape y las capas del portal se pueden combinar.
RECORDED INTERACTION
El harness abre Toggle Popover y muestra su contenido.
// ── Popover ──
pub fn buildPopover(scope: *ui.Scope, cx: *ui.Cx) anyerror!*ui.Node {
const a = cx.allocator;
const c = try col(cx, 12);
try c.appendChild(a, try label(cx, "Click the button to toggle the popover"));
const pop = try W.Popover(.{ .position = .bottom_start, .trigger = .click }).mount(scope, cx);
try pop.trigger.appendChild(a, try W.Button(.{ .label = "Toggle Popover" }).mount(scope, cx));
try pop.content.appendChild(a, try ui.text(cx, "Popover content!", .{ .font_size = 13, .color = light.color.fg_primary }));
try c.appendChild(a, pop.wrapper);
// ── 其它位置 + hover 触发 ──
try c.appendChild(a, try label(cx, "Positions (top / right) + hover trigger"));
const grid = try ui.box(cx, .{ .direction = .row, .gap = 24, .align_items = .center }, .{});
const ptop = try W.Popover(.{ .position = .top, .trigger = .click }).mount(scope, cx);
try popSetup(a, cx, scope, ptop, "Top", "Opens above");
try grid.appendChild(a, ptop.wrapper);
const pright = try W.Popover(.{ .position = .right, .trigger = .click }).mount(scope, cx);
try popSetup(a, cx, scope, pright, "Right", "Opens to the right");
try grid.appendChild(a, pright.wrapper);
const phover = try W.Popover(.{ .position = .bottom, .trigger = .hover }).mount(scope, cx);
try popSetup(a, cx, scope, phover, "Hover me", "Hover-triggered");
try grid.appendChild(a, phover.wrapper);
try c.appendChild(a, grid);
// ── 块类型下拉(与编辑器选区工具条的 block type 菜单同构)──
// 回归锚(e2e "popover: block dropdown shadow"):白底 + 圆角 10 + 1px 描边 +
// 双层阴影(contact 0/1/3 + ambient 0/8/28)+ fade_fast。阴影必须完整柔和地
// 落在面板四周,圆角外不得出现被矩形裁出来的灰块。
try c.appendChild(a, try label(cx, "Block dropdown (editor toolbar shape): shadow must stay soft around rounded corners"));
const pblock = try W.Popover(.{
.position = .bottom_start,
.trigger = .click,
.width = 168,
.offset = .{ .static = 6 },
.enter_transition = .fade_fast,
.exit_transition = .fade_fast,
.viewport_padding = 12,
.shift_main_axis = true,
.prewarm_hidden_layout = false,
.detach_hidden_content = true,
}).mount(scope, cx);
pblock.trigger.meta.ownership.meta.test_id = "story.popover.block.trigger";
pblock.chrome.meta.ownership.meta.test_id = "story.popover.block.content";
try pblock.trigger.appendChild(a, try W.Button(.{ .label = "Block Menu", .variant = .secondary }).mount(scope, cx));
{
const panel = pblock.chrome;
panel.style.direction = .column;
panel.style.align_items = .stretch;
panel.style.gap = 0;
panel.style.padding = ui.Padding.symmetric(4, 4);
panel.style.height = .{ .fit = .{} };
panel.setBackground(ui.Color.rgba(255, 255, 255, 255));
panel.style.border = .{ .radius = 10, .width = 1, .color = ui.Color.rgba(224, 224, 228, 255) };
panel.style.overflow_hidden = false;
const ext = panel.style.ensureExtPanic(cx.allocator);
ext.z_index = 82; // 下游编辑器浮层同款:覆盖 popover 分配的层级
ext.setShadows(
.{ .color = ui.Color.rgba(0, 0, 0, 15), .blur = 3, .offset_y = 1 },
.{ .color = ui.Color.rgba(0, 0, 0, 36), .blur = 28, .offset_y = 8 },
);
const rows = [_][]const u8{ "Text", "Heading 1", "Heading 2", "Heading 3", "Quote", "Bullet", "Task", "Code block" };
for (rows) |row_label| {
const menu_row = try ui.box(cx, .{
.width = .{ .grow = .{} },
.height = .{ .px = 28 },
.direction = .row,
.align_items = .center,
.padding = ui.Padding.symmetric(0, 8),
.border = .{ .radius = 6 },
}, .{});
try menu_row.appendChild(a, try ui.text(cx, row_label, .{ .font_size = 13, .color = light.color.fg_primary }));
try panel.appendChild(a, menu_row);
}
}
try c.appendChild(a, pblock.wrapper);
// ── 对照:静态(非合成层)overflow_hidden 圆角卡片 + 阴影 + 溢出蓝块 ──
// 与 tall popover 同一组视觉合同,但不经过 composited surface:
// 阴影不被自身 clip 裁、蓝块被圆角内沿裁、描边不被内容盖住。
try c.appendChild(a, try label(cx, "Static overflow_hidden card (reference): shadow outside, content clipped inside the border"));
const card = try ui.box(cx, .{
.direction = .column,
.padding = ui.Padding.all(8),
.width = .{ .px = 220 },
.height = .{ .px = 80 },
.background = light.color.bg_secondary,
.border = .{ .radius = 10, .width = 1, .color = light.color.border },
.overflow_hidden = true,
}, .{});
card.meta.ownership.meta.test_id = "story.popover.static.card";
{
const ext = card.style.ensureExtPanic(cx.allocator);
ext.clip_shape = .{ .rounded_rect = 10 };
ext.setShadows(
.{ .color = ui.Color.rgba(0, 0, 0, 40), .blur = 12, .offset_y = 4 },
.{ .color = ui.Color.rgba(0, 0, 0, 18), .blur = 40, .offset_y = 16 },
);
}
// 竖向渐变:漏出卡片下沿的是哪一段一眼可辨(纯色看不出溢出/滚动位置)
try card.appendChild(a, try storyGradientSlab(cx, 204, 400));
try c.appendChild(a, card);
// ── 超高内容:两侧都放不下 → autosize 把 max_height 收紧到可用高度 ──
// 回归锚(e2e "popover: tall content"):修前面板保持完整高度、best-fit 只挪
// translate,下缘越过 trigger 把 reference element 盖住。
try c.appendChild(a, try label(cx, "Tall content: panel must shrink to the viewport, never cover its trigger"));
// fit_or_scroll:autosize 把 max_height 收到可用高度后,超出部分在面板内纵向滚动
const ptall = try W.Popover(.{
.position = .bottom_start,
.trigger = .click,
.size_policy = .fit_or_scroll,
.max_width = 236,
.max_height = 2000,
}).mount(scope, cx);
ptall.trigger.meta.ownership.meta.test_id = "story.popover.tall.trigger";
// fit_or_scroll 下 content 是 ScrollArea 内容节点、chrome 才是面板外壳
ptall.chrome.meta.ownership.meta.test_id = "story.popover.tall.content";
ptall.content.meta.ownership.meta.test_id = "story.popover.tall.scroll_content";
try ptall.trigger.appendChild(a, try W.Button(.{ .label = "Tall Popover", .variant = .secondary }).mount(scope, cx));
const tall = try ui.box(cx, .{ .direction = .column, .gap = 6, .padding = ui.Padding.all(8), .width = .{ .px = 220 }, .height = .{ .fit = .{} } }, .{});
try tall.appendChild(a, try ui.text(cx, "Top of tall content", .{ .font_size = 13, .color = light.color.fg_primary }));
// 1400px 渐变块:任何合理窗口高度都放不下;渐变让滚动位置可见
try tall.appendChild(a, try storyGradientSlab(cx, 204, 1400));
try tall.appendChild(a, try ui.text(cx, "Bottom of tall content", .{ .font_size = 13, .color = light.color.fg_primary }));
try ptall.content.appendChild(a, tall);
try c.appendChild(a, ptall.wrapper);
return c;
}El código literal de la story en el Storybook (zenit 5f9add5+wip 2026-09-30). W es ui.widgets; col / row / label son pequeños helpers de layout del Storybook.
PopoverProps
ui.widgets.Popoverfn Popover(props: PopoverProps) PopoverBuilderCampo
Tipo
Predeterminado
Descripción
positionPopoverPosition.top.top_start.top_end.bottom.bottom_start.bottom_end.left.left_start.left_end.right.right_start.right_end.bottom_start位置
triggerPopoverTrigger.click.hover.manual.click触发方式
offsetPopoverOffset.{ .static = 4 }与触发元素的 main-axis 间距 —— 支持静态 / derivable(按 placement 动态算)。 默认静态 4 px。Caller 写: `.offset = .{ .static = 8 }` 或 `.offset = .{ .derive = .{ .ctx = ..., .compute = computeFn } }`
visible?*Signal(bool)null外部控制显隐 Signal
close_on_outside_clickbooltrue点击外部关闭
consume_outside_clickboolfalse点击外部关闭时吞掉这一下(不同时触发点中的东西)。需 close_on_outside_click。
close_on_escapebooltrueEscape 关闭
width?f32null内容宽度
max_width?f32null内容最大宽度(auto-fit 时常用,超出后由内部内容自行 wrap)
match_trigger_widthboolfalse打开时令浮层宽度跟随 trigger 宽度
constrain_width_to_viewportboolfalse允许固定/匹配宽度在视口内收缩
max_height?f32null内容最大高度
viewport_paddingf328视口约束时的安全边距
flipbooltrue自动翻转:当首选 placement 溢出时自动选择最佳位置(参考 floating-ui flip)
shift_main_axisboolfalse主轴方向也启用 shift 推回 viewport(默认 false 仅 cross-axis 推回)。 启用时即使 popover 没空间放下,也会被推回 viewport 内(可能 overlap anchor), 比飞屏外更友好。典型场景:completion docs 在小窗口下放不进 fallback placements 时。
fallback_placements[]const PopoverPosition&.{}可选的 fallback placement 列表;非空时按顺序尝试 [position, ...fallback_placements], 挑第一个不溢出的。空时走经典 2-way flip(position ↔ opposite)。 示例(docs aside panel):`.fallback_placements = &.{.bottom_start, .top_start}` 配合 `.position = .right_start` → 右侧 → 下方 → 上方,**永不 flip 到左**。
fallback_placements_ptr?*[]const PopoverPositionnull可选:指向 caller 拥有的 mutable slice 的指针,**每帧被 popover 读**来决定 fallback 列表。 若非 null,覆盖 `fallback_placements` 字段。用于"根据其他 popover 的 active_position 动态切换自己的 fallback"场景(e.g. docs panel 要贴 main popup 所在的那一侧)。
open_delay_msf320入场延迟(ms):打开后先不可见持有这么久再播 enter_transition; 期间关闭则直接消失。tooltip display delay 用。
enter_transitionoverlay_stack_mod.Transition.scale_fade入场动画
tier?overlay_stack_mod.StackTiernull语义 z-index tier;null = overlay(Tooltip 传 .tooltip)。见 StackTier。
exit_transitionoverlay_stack_mod.Transition.scale_fade退场动画
prewarm_hidden_layoutboolfalse关闭时是否保留可测量隐藏布局,用于首帧预热
detach_hidden_contentbooltrue关闭且退出动画完成后,把浮层内容从 wrapper 子树摘下。 内容节点本身会保留,重新打开时再挂回;这样未显示内容不出现在 root box tree。
a11y_role?core.A11yRolenull无障碍角色(null = 不设 role,由上层组件覆盖)
anchor?Anchornull自定义锚点:null 时锚点 = 内部 trigger_node(默认行为) 提供 anchor 时定位读 anchor,trigger_node 仍存在但不参与位置计算 (依然是 dismiss outside-click 判定的"trigger"——避免点击 trigger 触发 dismiss)。 trigger=.manual + visible signal 控制下,virtual anchor 是最常用的组合。
size_policySizePolicy.hard_clip.fit_content.fit_or_scroll.hard_clip尺寸策略(见 SizePolicy 文档)。默认 .hard_clip 保持向后兼容; .fit_or_scroll 仍在调试(fit 父里 ScrollArea grow 退化导致内容塌缩)。
clip_subtree_to_chromeboolfalsevisible 时是否保留 chrome.style.overflow_hidden=true(让 chrome 边界裁切子节点 raster)。 默认 false(旧行为:visible 时无条件关 overflow_hidden)。 配合 surface owner self-clip 路径(compositor_plan apply_clip op)+ 修复后的嵌套 surface offset 使用。 适用:caller 子节点带 background 且需要被 chrome 圆角真正裁切(如 completion popup hint_bar)。
avoid_rects_provider?*const fn (cx: *Cx, buf: []floating.Rect) []const floating.Rectnull避让矩形提供者:popoverBeforeRender 每帧调用,返回的 rect 列表作为 excluded_rects 喂给 floating-ui 的 flip/shift/autosize middleware,让该 popover 的 placement 选择 避开这些 rect(视为"墙")。典型用法:signature popup 需要避让 completion chrome 和 docs chrome 的当前位置。null 表示不避让任何 rect(单 popover 独立计算)。 buf 由 caller 提供(栈上数组即可),provider 写入后返回 slice。
PopoverResult
ui.widgets.PopoverCampo
Tipo
Predeterminado
Descripción
wrapper*Node—
—
trigger*Node—
—
content*Node—
caller 把内容 appendChild 到这里。 - hard_clip / fit_content: == chrome - fit_or_scroll: == ScrollArea content(chrome 是另一节点,由 popover 自己管 overflow)
chrome*Node—
caller 用来配 popover panel 的 background / border / shadow / panel padding。 大多数模式下 == content;fit_or_scroll 模式下 = popover 外壳节点。
is_open*Signal(bool)—
—
active_position_ptr*const PopoverPosition—
指向内部 ctx.active_position 的指针:可供 caller **每帧读取** popover 实际落位。 用于"根据另一 popover 的 active_position 决定自己的 fallback_placements"场景。
portaledbool—
向后兼容字段,现在恒为 false。wrapper 只承载 trigger,永远由 caller 挂到正常文档树;floating content 则独立挂到 window portal。