Popover
ui.widgets.Popover承载比 Tooltip 更丰富的临时内容。Richer transient content than a tooltip.
BEHAVIOR CONTRACT
click / hover trigger、位置计算、outside click、Escape 和 portal 层级可组合。click / hover triggers, positioning, outside click, Escape and portal layering compose.
RECORDED INTERACTION
Harness 打开 Toggle Popover 并展示内容。The harness opens Toggle Popover and shows its content.
// ── 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;
}这是 Storybook 中该 story 的原样代码(zenit 5f9add5+wip 2026-09-30)。其中 W 即 ui.widgets,col / row / label 是 Storybook 内的布局小工具。The story's verbatim source from the Storybook (zenit 5f9add5+wip 2026-09-30). W is ui.widgets; col / row / label are small Storybook layout helpers.