Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,8 @@ final object app.tinyui.components.generated/BuiltinSchemas { // app.tinyui.comp
final fun <get-Loading>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Loading.<get-Loading>|<get-Loading>(){}[0]
final val Option // app.tinyui.components.generated/BuiltinSchemas.Option|{}Option[0]
final fun <get-Option>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Option.<get-Option>|<get-Option>(){}[0]
final val Popover // app.tinyui.components.generated/BuiltinSchemas.Popover|{}Popover[0]
final fun <get-Popover>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Popover.<get-Popover>|<get-Popover>(){}[0]
final val RadioButton // app.tinyui.components.generated/BuiltinSchemas.RadioButton|{}RadioButton[0]
final fun <get-RadioButton>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.RadioButton.<get-RadioButton>|<get-RadioButton>(){}[0]
final val Row // app.tinyui.components.generated/BuiltinSchemas.Row|{}Row[0]
Expand Down
85 changes: 85 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package app.tinyui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
Expand All @@ -13,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyListState
Expand Down Expand Up @@ -44,6 +49,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberBottomSheetState
Expand All @@ -65,6 +71,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentType
Expand All @@ -86,6 +95,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
Expand Down Expand Up @@ -177,6 +193,7 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply {
}
register(BuiltinSchemas.Loading) { scope -> LoadingComponent(scope) }
register(BuiltinSchemas.BottomSheet) { scope -> BottomSheetComponent(scope) }
register(BuiltinSchemas.Popover) { scope -> PopoverComponent(scope) }
register(BuiltinSchemas.Divider) { scope ->
HorizontalDivider(
modifier = scope.modifier(),
Expand Down Expand Up @@ -491,3 +508,71 @@ private fun BottomSheetComponent(scope: NodeScope) {
) { scope.Children() }
}
}

/**
* A card next to the parent node, which is the anchor: Compose's `Popup` anchors to the layout it is called in, and
* built-ins render their children without wrappers. Visibility follows `open` (faded); a tap outside is only reported,
* since the field the user just tapped is "outside" too. Never focusable: a hint under a focused field must not take
* the keyboard away.
*/
@Composable
private fun PopoverComponent(scope: NodeScope) {
val visible = remember { MutableTransitionState(false) }
visible.targetState = scope.get<Boolean>("open") == true
// stays composed while fading out
if (!visible.currentState && !visible.targetState) return
val matchWidth = scope.get<Boolean>("matchAnchorWidth") == true
var anchorWidth by remember { mutableStateOf<Int?>(null) }
if (matchWidth) {
// the same zero-size probe Popup itself uses to find its parent
Layout(content = {}, modifier = Modifier.onGloballyPositioned { anchorWidth = it.parentLayoutCoordinates?.size?.width }) { _, _ -> layout(0, 0) {} }
// not drawn until the width is known, so it never shows at the wrong width first
if (anchorWidth == null) return
}
val gap = with(LocalDensity.current) { POPOVER_GAP.roundToPx() }
val above = scope.get<String>("placement") == "above"
val position = remember(above, gap) {
object : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize) =
popoverOffset(anchorBounds, windowSize, popupContentSize, above, gap, layoutDirection == LayoutDirection.Rtl)
}
}
val width = anchorWidth?.takeIf { matchWidth }?.let { with(LocalDensity.current) { it.toDp() } }
Popup(
popupPositionProvider = position,
onDismissRequest = { if (scope.has("onDismiss")) scope.dispatch("onDismiss") },
properties = PopupProperties(focusable = false),
) {
AnimatedVisibility(visibleState = visible, enter = fadeIn(), exit = fadeOut()) {
Surface(
modifier = 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
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 3.dp,
) { scope.Children() }
}
}
}

private val POPOVER_GAP = 8.dp

/**
* Where a popover of [content] size goes: on the [above] / below side of [anchor] with [gap] px between, start edges
* aligned. The preferred side wins when it fits, else the other side when that fits, else the roomier side; the result
* is kept inside [window] on both axes.
*/
internal fun popoverOffset(anchor: IntRect, window: IntSize, content: IntSize, above: Boolean, gap: Int, rtl: Boolean): IntOffset {
val topY = anchor.top - gap - content.height
val bottomY = anchor.bottom + gap
val fitsTop = topY >= 0
val fitsBottom = bottomY + content.height <= window.height
val roomierTop = anchor.top > window.height - anchor.bottom
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)。

(if (onTop) topY else bottomY).coerceIn(0, (window.height - content.height).coerceAtLeast(0)),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,23 @@ object BuiltinSchemas {
layout = false,
)

val all: List<ComponentSchema> = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading, BottomSheet, Divider, Checkbox, CodeField, Select, Option)
val Popover = ComponentSchema(
type = "Popover",
props = mapOf(
"open" to PropSpec.Bool(default = null, required = true, initial = false),
"placement" to PropSpec.Enum(values = setOf("above", "below"), default = "below", required = false, initial = false),
"matchAnchorWidth" to PropSpec.Bool(default = false, required = false, initial = false),
),
events = mapOf(
"onDismiss" to emptyMap(),
),
commands = mapOf(
),
children = true,
layout = false,
)

val all: List<ComponentSchema> = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading, BottomSheet, Divider, Checkbox, CodeField, Select, Option, Popover)

/** Theme token names the schema DSL knows; Theme.kt must resolve each (checked by test). */
val colorTokens: Set<String> = setOf("primary", "onPrimary", "primaryContainer", "onPrimaryContainer", "inversePrimary", "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", "background", "onBackground", "surface", "onSurface", "surfaceVariant", "onSurfaceVariant", "surfaceTint", "inverseSurface", "inverseOnSurface", "error", "onError", "errorContainer", "onErrorContainer", "outline", "outlineVariant", "scrim", "surfaceBright", "surfaceDim", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", "primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant", "secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant", "tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant")
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class PageHostTest {
val host = host("""{"name":"tinyui"}""")
host.start()
host.await { host.tree.root.children.isNotEmpty() }
assertEquals("hello tinyui / 17", host.tree.node(1)!!.props["text"])
assertEquals("hello tinyui / 18", host.tree.node(1)!!.props["text"])
assertNull(host.failure)
host.close()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package app.tinyui.components

import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import kotlin.test.Test
import kotlin.test.assertEquals

class PopoverOffsetTest {
private val window = IntSize(400, 800)
// a field across the middle of the screen, 16 px in from each side
private val field = IntRect(16, 300, 384, 356)
private val card = IntSize(368, 200)

@Test
fun abovePutsTheBottomEdgeAGapOverTheAnchor() =
assertEquals(IntOffset(16, 92), popoverOffset(field, window, card, above = true, gap = 8, rtl = false))

@Test
fun belowPutsTheTopEdgeAGapUnderTheAnchor() =
assertEquals(IntOffset(16, 364), popoverOffset(field, window, card, above = false, gap = 8, rtl = false))

@Test
fun flipsWhenThePreferredSideHasNoRoom() {
val nearTop = IntRect(16, 100, 384, 156)
assertEquals(IntOffset(16, 164), popoverOffset(nearTop, window, card, above = true, gap = 8, rtl = false))
val nearBottom = IntRect(16, 600, 384, 656)
assertEquals(IntOffset(16, 392), popoverOffset(nearBottom, window, card, above = false, gap = 8, rtl = false))
}

@Test
fun fitsNeitherSideTakesTheRoomierOneAndStaysInTheWindow() {
val tall = IntSize(368, 500)
// 300 above vs 444 below: below, pushed up so the bottom edge stays on screen
assertEquals(IntOffset(16, 300), popoverOffset(field, window, tall, above = true, gap = 8, rtl = false))
}

@Test
fun startEdgesAlignAndNeverLeaveTheWindow() {
val narrow = IntSize(100, 50)
assertEquals(IntOffset(284, 242), popoverOffset(field, window, narrow, above = true, gap = 8, rtl = true))
val pastRightEdge = IntRect(350, 300, 390, 340)
assertEquals(IntOffset(300, 242), popoverOffset(pastRightEdge, window, narrow, above = true, gap = 8, rtl = false))
}

@Test
fun aFullWidthAnchorGetsAFullWidthCardFlushWithTheEdges() {
// Popup measures its content against the window, so the card is never wider than the window
val edgeToEdge = IntRect(0, 300, 400, 356)
assertEquals(IntOffset(0, 92), popoverOffset(edgeToEdge, window, IntSize(400, 200), above = true, gap = 8, rtl = false))
assertEquals(IntOffset(0, 92), popoverOffset(edgeToEdge, window, IntSize(400, 200), above = true, gap = 8, rtl = true))
}
}
17 changes: 17 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB
| `Icon` | `icon`(必填,`icon` 类型)、`size`(dp,默认 24)、`tint`(color,缺省跟随内容色)、`label`(读屏念的名字;紧挨文字的装饰图标不设) | `onClick` | | |
| `Loading` | `size`(dp,默认 24)、`color`(缺省 `primary`) | | | |
| `BottomSheet` | `open`(必填);不接受布局 prop | `onDismiss` | | 是:弹层的内容 |
| `Popover` | `open`(必填)、`placement`(above / below,默认 below)、`matchAnchorWidth`;不接受布局 prop | `onDismiss`(点了弹层外面) | | 是:卡片的内容 |
| `Divider` | `thickness`(dp,默认 1)、`color`(默认 `outlineVariant`) | | | |

`RadioButton` 的选中态是普通 prop(2026-09-17 加):它只报点击,选中哪个由 JS 决定——单选组的真值本来就在页面状态里,不属于 ADR-004 的"高频交互状态"。`TextField` 的文本与光标永远在 Kotlin 侧(ADR-004 §3.3):`setText` 会同时触发 `onChange`。`LazyColumn` 的行就是它的 children,虚拟化只在组合层(ADR-003 §3.6)。
Expand Down Expand Up @@ -124,6 +125,22 @@ const [open, setOpen] = signal(false);
</BottomSheet>
```

`Popover` 是锚在父节点上的浮动卡片(2026-10-29 加,注册流程触发:密码框聚焦时在它上方浮出规则清单,盖住上面的内容而不推开布局)。锚点就是父节点,不另设 ref:Compose `Popup` 本来就锚在调用处的父布局上,内置组件渲染 children 也不加包装。所以写法是用 `Box` 把锚元素和它包在一起;直接放进带 `gap` 的 `Column` 会锚到整列,而且它在组合里占一个零尺寸的位置,打开时会多出一份 `gap`。`placement` 是首选的一侧,那一侧放不下就翻到另一侧,两侧都放不下取空间大的一侧;水平方向与锚点起始边对齐,两个方向都不出窗口,页面滚动时跟着锚点走。`matchAnchorWidth` 让卡片与锚点同宽(输入框下的说明),不设就按内容宽(图标按钮旁的提示)。

它**永不获取焦点**:锚在聚焦输入框上的提示不能把键盘收走。显隐完全跟 `open`(淡入淡出);点卡片外面只报 `onDismiss`、不自己收起,因为用户刚点的那个输入框也算"外面"——要不要关由页面决定,密码规则这种场景只跟焦点走、不监听它。外观归框架:`surface` 底、`onSurface` 字(显式给出,不靠 `contentColorFor` 从容器色推)、medium 圆角、3dp 阴影;内边距与内容由页面用 children 写。不做:箭头、背景色 prop、左右两侧弹出。

```tsx
const [focused, setFocused] = signal(false);
<Box width="fill">
<TextField width="fill" keyboard="password" onFocusChange={(e) => setFocused(e.focused)} … />
<Popover open={focused()} placement="above" matchAnchorWidth>
<Column width="fill" padding={16} gap={8}>…</Column>
</Popover>
</Box>
```

叫 `Popover` 而不叫 `Tooltip`:M3 里最接近的是 rich tooltip,但 M3 的 tooltip 由框架在长按、悬停时自己触发,偏短文字,宽度上限 320dp;这里是页面掌握开关、内容不限的锚定浮层,与 Web(Radix、MUI 的 `Popover` / `Tooltip` 并存)和 iOS 的 popover 同义。`Tooltip` 这个名字留给将来按 M3 语义实现的那个(图标按钮长按出一句说明)。

`Divider` 是 M3 `HorizontalDivider`:占满宽度的一条线,缩进用 `paddingHorizontal`(2026-10-12 加,与 Compose 组件对齐;像素上等同 `<Box width="fill" height={1} background="outlineVariant" />`,内置是为了写法与默认值跟 M3 走)。不做:竖线(`VerticalDivider` 要靠 `IntrinsicSize` 撑高,框架还没有内在尺寸)与一像素 hairline(尺寸只按 dp 过桥)。

**`Image` 推迟**:牵出图片加载管线的选型(coil3 还是宿主提供 loader),M2 用不到,单独一次定。
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/generated/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,20 @@ export interface OptionProps {
text: string;
}

export interface PopoverDismissPayload {
}

/** Card floating next to its parent, which is the anchor; children are its content. Shown while `open`; it never takes focus, so the keyboard stays up. `onDismiss` reports a tap outside, the page decides whether to close. */
export interface PopoverProps {
open: boolean;
/** preferred side of the anchor; flips when that side has no room */
placement?: "above" | "below";
/** as wide as the anchor instead of its content */
matchAnchorWidth?: boolean;
onDismiss?: (e: PopoverDismissPayload) => void;
children?: Node | Node[];
}

export interface IntrinsicElements {
Column: ColumnProps;
Row: RowProps;
Expand All @@ -328,6 +342,7 @@ export interface IntrinsicElements {
CodeField: CodeFieldProps;
Select: SelectProps;
Option: OptionProps;
Popover: PopoverProps;
}

export const Column = "Column";
Expand All @@ -347,3 +362,4 @@ export const Checkbox = "Checkbox";
export const CodeField = "CodeField";
export const Select = "Select";
export const Option = "Option";
export const Popover = "Popover";
Loading
Loading