Skip to content

feat: Popover——锚在父节点上的浮动卡片,不抢焦点、放不下自动翻转 - #48

Merged
HarlonWang merged 2 commits into
mainfrom
feat/popover
Oct 29, 2026
Merged

HarlonWang merged 2 commits into
mainfrom
feat/popover

Conversation

@HarlonWang

@HarlonWang HarlonWang commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

一个宿主的注册流程逼出的内置组件:密码框聚焦时在它上方浮出规则清单,盖住上面的内容而不推开布局。随 0.14.0 一起发。

改动

项 内容
schema Popover:open(必填)、placement(above / below,默认 below)、matchAnchorWidth;事件 onDismiss;children 是卡片内容;layout: false
锚点 父节点,不加 ref:Compose Popup 本来就锚在调用处的父布局上,内置组件渲染 children 不加包装。写法是 Box 把锚元素和它包在一起
位置 纯函数 popoverOffset:首选侧放得下用首选侧,否则另一侧,都放不下取空间大的一侧;起始边对齐(RTL 对齐右边);两轴不出窗口。滚动时跟随锚点
焦点 PopupProperties(focusable = false):锚在聚焦输入框上不收键盘
显隐 完全跟 open,淡入淡出;点外面只报 onDismiss 不自己收(刚点的输入框也算外面)
外观 surface 底、onSurface 字(显式,不靠 contentColorFor)、medium 圆角、3dp 阴影(双端 Popup 窗口都不裁阴影,不留边距)

命名:M3 最接近的是 rich tooltip,但 M3 tooltip 由框架长按 / 悬停触发、偏短文字、宽度上限 320dp;这里是页面掌握开关、内容不限的锚定浮层,与 Web / iOS 的 popover 同义。Tooltip 留给将来按 M3 语义实现的那个。不做:箭头、背景色 prop、左右弹出。

验证

  • pnpm schema:check / runtime:check / typecheck / test;:compose:testAndroidHostTest(新增 PopoverOffsetTest 六例,含全宽锚点;PageHostTest 内置组件数 17 → 18)、:compose:apiCheck、:sample:shared:testAndroidHostTest
  • sample-extra form 页的密码框改用 Popover 展示规则:Android 模拟器(Pixel 10)与 iOS 模拟器(iPhone 17)上,聚焦浮在上方且与输入框同宽、键盘不收、输入时规则逐条变色;上滑页面使上方放不下时翻到下方,并跟随滚动

🤖 Generated with Claude Code

Summary by Sourcery

Add a non-focusable, anchor-relative Popover component for displaying floating content without affecting layout.

New Features:

  • Add a built-in Popover component for anchor-relative floating content with configurable visibility, placement, and anchor-width matching.
  • Use the Popover in the form sample to display password requirements while the password field is focused.

Enhancements:

  • Keep popovers non-focusable so they do not dismiss the keyboard or disrupt the focused anchor.
  • Automatically reposition popovers to remain within the window and follow the anchor as available space changes.
  • Document the Popover API, anchoring behavior, dismissal semantics, and usage guidance.

Documentation:

  • Document the Popover component and provide a password-validation example.

Tests:

  • Add coverage for preferred-side placement, flipping, constrained positioning, RTL alignment, window bounds, and full-width anchors.

注册流程里密码框聚焦时要在它上方浮出规则清单,盖住上面的内容而不推开布局。
锚点就是父节点(Compose Popup 的锚定方式),显隐跟 open,点外面只报 onDismiss;
placement 首选一侧放不下就翻转,matchAnchorWidth 与锚点同宽。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR introduces the Popover built-in end to end—from schema and generated bindings through Compose runtime implementation and documentation—and demonstrates it in the password form. The implementation uses a parent-anchored, non-focusable Popup with fade transitions, explicit Material styling, outside-dismiss reporting, optional anchor-width matching, and pure tested placement logic that flips and clamps within the window.

Sequence diagram for password rule Popover interaction

sequenceDiagram
    actor User
    participant PasswordField
    participant Popover
    participant ComposePopup
    participant PageState

    User->>PasswordField: onFocusChange
    PasswordField->>PageState: setFocused
    PageState->>Popover: open = focused
    Popover->>ComposePopup: Popup(focusable = false)
    ComposePopup-->>Popover: Follow parent anchor
    User->>PasswordField: onChange
    PasswordField->>PageState: setPassword
    PageState-->>Popover: Update rule children
    User->>ComposePopup: Tap outside
    ComposePopup->>Popover: onDismissRequest
    Popover->>PageState: dispatch onDismiss
Loading

Flow diagram for Popover placement and rendering

flowchart TD
    A[Popover receives open] --> B{open is true}
    B -- No --> C[Do not render]
    B -- Yes --> D[Measure parent anchor]
    D --> E[Select preferred side]
    E --> F{Preferred side fits}
    F -- Yes --> G[Place with gap]
    F -- No --> H{Other side fits}
    H -- Yes --> I[Flip to other side]
    H -- No --> J[Choose roomier side]
    J --> K[Clamp both axes to window]
    I --> K
    G --> K
    K --> L[Render non-focusable Popup]
    L --> M[Fade in or fade out]
Loading

File-Level Changes

Change Details Files
Added the schema, generated bindings, runtime registration, and documentation for a non-layout Popover built-in component.
  • Defined required open state, preferred placement, optional anchor-width matching, dismiss event, and children content.
  • Registered the component through generated schema/runtime artifacts and documented its anchoring and usage contract.
  • Updated host coverage to account for the additional built-in.
schema/components/popover.ts
schema/index.ts
compose/src/commonMain/kotlin/app/tinyui/components/generated/BuiltinSchemas.kt
compose/src/commonMain/kotlin/app/tinyui/generated/RuntimeBytecode.kt
packages/core/src/generated/components.ts
compose/api/compose.klib.api
compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt
docs/components.md
Implemented the Compose Popover as a non-focusable anchored Popup with animated visibility and explicit Material styling.
  • Anchored the popup to the parent layout without wrapping its children, with optional width matching measured from the anchor.
  • Kept the popup composed during fade-out and made outside taps dispatch onDismiss without changing open state.
  • Configured non-focusable behavior, surface/onSurface colors, medium shape, shadow elevation, and shadow-safe padding.
compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Added deterministic placement logic that flips vertically, aligns to the anchor start edge, and clamps within the window.
  • Preferred the requested above/below side when it fits, otherwise selected the available or roomier side.
  • Handled RTL horizontal alignment and constrained both axes to window bounds.
  • Covered normal placement, flipping, insufficient vertical space, RTL alignment, and edge clamping with unit tests.
compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
compose/src/commonTest/kotlin/app/tinyui/components/PopoverOffsetTest.kt
Reworked the password form sample to demonstrate the Popover interaction and responsive validation content.
  • Wrapped the password field and Popover in a Box so the field becomes the anchor.
  • Displayed password rules above the field at matching width while preserving keyboard focus and updating rule colors as input changes.
sample/js-extra/src/pages/form.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1b4c9e17-32f6-49de-b330-d1f4852b0a6c
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeSeer 审查

结论:批准(附 1 条建议)

摘要

本次改动新增了 Popover 的 schema、运行时注册、Compose 渲染和示例页面,并实现了基于父布局的上下翻转定位、非焦点 Popup 与淡入淡出。定位计算对常规带页边距的锚点能够保持卡片与锚点对齐。主要风险在于 matchAnchorWidth 与窗口同宽锚点组合时,给阴影预留的外层尺寸会使实际卡片越出窗口并被裁切。

整个 PR 至 84faaf5 · gpt-5.6-terra

val onTop = if (above) fitsTop || !fitsBottom && roomierTop else !fitsBottom && (fitsTop || roomierTop)
val x = if (rtl) anchor.right - content.width else anchor.left
return IntOffset(
x.coerceIn(0, (window.width - content.width).coerceAtLeast(0)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] matchAnchorWidth 时 popupContentSize 包含 Surface 外侧的 8dp padding,宽度会比锚点多 16dp;但这里将外层窗口的 x 限制为非负。若锚点本身铺满窗口,外层 x 会被钳到 0,而实际 Surface 从 x=8 开始、仍保持锚点宽度,右侧会越过窗口 8dp 并被 Popup 裁切,违背两轴不出窗口。应在计算中按实际卡片边界处理阴影边距(例如允许外层为负边距,或在边缘缩减/调整阴影留白),并补充全宽锚点的测试。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修(6c94794)。根因是卡片外那圈 8dp 阴影边距:当初以为阴影会被 Popup 窗口裁掉才加的,双端实测不留边距阴影也完整,于是直接去掉。现在 popup 内容就是卡片本身,全宽锚点时卡片与锚点同宽、x 为 0,不再越出窗口;补了 aFullWidthAnchorGetsAFullWidthCardFlushWithTheEdges(LTR / RTL)。

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="579-580" />
<code_context>
+    val onTop = if (above) fitsTop || !fitsBottom && roomierTop else !fitsBottom && (fitsTop || roomierTop)
+    val x = if (rtl) anchor.right - content.width else anchor.left
+    return IntOffset(
+        x.coerceIn(0, (window.width - content.width).coerceAtLeast(0)),
+        (if (onTop) topY else bottomY).coerceIn(0, (window.height - content.height).coerceAtLeast(0)),
+    )
+}
</code_context>
<issue_to_address>
**Oversized popovers get clipped**

When popover content is larger than the popup window on either axis, `popoverOffset` clamps the card origin but leaves its size unchanged, so part of the card extends beyond the window and users see clipped or off-screen content.

Constrain popover content to the available window dimensions before applying the offset.
</issue_to_address>

### Comment 2
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="551" />
<code_context>
+        AnimatedVisibility(visibleState = visible, enter = fadeIn(), exit = fadeOut()) {
+            Surface(
+                // the popup window clips to its content: leave the shadow room to fall
+                modifier = Modifier.padding(POPOVER_SHADOW_ROOM).then(if (width != null) Modifier.width(width) else Modifier),
+                shape = MaterialTheme.shapes.medium,
+                // explicit: contentColorFor would guess from the container and can land on a muted colour
</code_context>
<issue_to_address>
**Full-width cards render too narrow**

When `matchAnchorWidth` is true and the anchor fills the available window width, `Modifier.padding(8.dp)` reduces the available width before `Modifier.width(width)` is applied, so the card is constrained to 16dp less than the measured anchor width; callers using a full-width anchor see a narrower card.

Account for the horizontal shadow padding in the outer width constraint, or avoid consuming horizontal width for the shadow margin.
</issue_to_address>

### Comment 3
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="539" />
<code_context>
+        object : PopupPositionProvider {
+            // the window is the card plus its margin all round: place that so the card itself lines up with the anchor
+            override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize) =
+                popoverOffset(anchorBounds.inflate(margin), windowSize, popupContentSize, above, gap - 2 * margin, layoutDirection == LayoutDirection.Rtl)
+        }
+    }
</code_context>
<issue_to_address>
**Tied overflow picks the wrong side**

When neither side fits and the actual available space below exceeds the space above by less than 16px, `anchorBounds.inflate(margin)` expands both sides, but `popoverOffset` compares those expanded spaces in `roomierTop`; the 8px shadow margin biases the comparison toward above by 16px, so it chooses the smaller side for near-ties and the card overlaps more of the anchor or surrounding content.

Compare the actual anchor’s available space on each side, or compensate for the inflation when choosing the roomier side.

Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:575`.
</issue_to_address>

Sourcery assessment

Approval pending. 3 findings to address first.

Blocking findings: compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:580, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:551, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:539


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

边距是误判阴影被 Popup 窗口裁掉加的,双端实测不留边距阴影也完整。
它让全宽锚点时卡片窄 16dp 或越出窗口,两侧都放不下时比较也偏向上方。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeSeer 审查

结论:批准(附 1 条建议)

上轮意见:1 条,已处理 0 条,待处理 1 条

  • compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:575 [建议] matchAnchorWidth 时 popupContentSize 包含 Surface 外侧的 8dp padding,宽度…

摘要

本次增量移除了 Popover 为阴影预留的内边距,并相应简化了位置计算,使全宽锚点能够让卡片贴齐窗口边缘。新增测试覆盖了全宽锚点在 LTR/RTL 下的定位。结合给出的代码,未发现此次增量引入的可确认问题。

增量 84faaf5..6c94794 · gpt-5.6-terra

@HarlonWang
HarlonWang merged commit 8b441ea into main Oct 29, 2026
5 checks passed
@HarlonWang
HarlonWang deleted the feat/popover branch October 29, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant