Repository navigation
feat: 注册流程组件——Checkbox、CodeField、Select / Option、TextField 外观与焦点、Text 内联链接 - #43
Conversation
…内联链接 PingPong 注册试点(TinyUI 页做注册)逼出的五项内置组件: - Checkbox:与 RadioButton 同模式,只报点击,checked 归页面 - TextField:secure(缺省跟 keyboard="password")、error、supportingText、prefixText(leading 槽,可点 onPrefixClick)、trailingIcon + onTrailingClick、imeAction done / next、maxLength;新事件 onFocusChange。顺带修首帧未聚焦被当作失焦、误发 onCommit - PinField:一排数字格画在一个隐藏输入框上,焦点与 IME 归 Kotlin;onChange / onComplete,命令 setText / focus / clear - Select + Option:M3 ExposedDropdownMenuBox,选项走 Option children;选中值叫 value(selected 是公共布局 prop) - Text:给了 onLinkClick 时 `[文案](id)` 渲染成链接 sample-extra 加 form 页演示,Android 模拟器与 iOS 模拟器逐项验过;components.md §3 同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
Reviewer's Guide本 PR 从 schema 到生成 API、Compose 注册实现、文档和 sample 端到端加入注册流程组件:新增 Checkbox、PinField、Select/Option,扩展 TextField 的外观/焦点/输入行为,并为 Text 提供可选内联链接解析;实现重点是保持交互状态与输入法在 Kotlin 侧、由页面 props/events/commands 驱动状态同步。 Sequence diagram for PIN entry and validationsequenceDiagram
participant Page as RegistrationPage
participant Pin as PinField
participant Kotlin as KotlinInputState
Page->>Pin: render secure and error
Pin->>Kotlin: onValueChange(digits)
Kotlin-->>Page: onChange(text)
alt PIN reaches length
Kotlin-->>Page: onComplete(text)
Page->>Pin: setText(text) or clear()
Pin->>Kotlin: update command
Kotlin-->>Page: onChange(text)
end
Sequence diagram for TextField focus and input interactionssequenceDiagram
participant Page as RegistrationPage
participant Field as TextField
participant Kotlin as KotlinInputState
Page->>Field: render props
Field->>Kotlin: onValueChange(text)
Kotlin-->>Page: onChange(text)
Kotlin-->>Page: onFocusChange(focused)
alt IME action is next
Page->>Field: imeAction=next
Field-->>Page: onCommit(text)
Field->>Kotlin: moveFocus(FocusDirection.Next)
else focus is lost
Field-->>Page: onCommit(text)
end
Page->>Field: setText(text) or focus()
Field->>Kotlin: update input state
Flow diagram for controlled Select and inline Text linksflowchart TD
Select["Select value"] --> Options["Option children"]
Options --> Dropdown["Exposed dropdown"]
Dropdown -->|onSelect(value)| Page["Page state"]
Page -->|updated value| Select
Text["Text with [label](id)"] -->|onLinkClick(id)| Page
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 5 条建议)
摘要
本次改动新增了注册流程所需的组件和 TextField/Text 的交互能力,并同步更新 schema、生成代码及示例。实现整体覆盖了声明的主要功能,但 TextField 的 imeAction="next" 会重复提交,且部分动态 prop 和命令语义没有保持约束。Select 在已展开时切换为禁用状态仍可选择选项,也需要补齐状态收敛。
整个 PR 至 5354c78 · gpt-5.6-terra
There was a problem hiding this comment.
Hey - I've found 11 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="schema/components/pin-field.ts" line_range="7" />
<code_context>
+ doc: "A row of digit cells over one input; text, focus and the IME stay on the Kotlin side like TextField (docs/adr-004 §3.3).",
+ props: {
+ length: number({ default: 6, doc: "number of cells; input stops there" }),
+ secure: boolean({ default: false, doc: "shows a dot instead of each digit" }),
+ enabled: boolean({ default: true }),
+ error: boolean({ default: false, doc: "error colour on the cell outlines" }),
</code_context>
<issue_to_address>
**PIN digits appear unmasked by default**
When a page uses `PinField` without setting `secure` to true, the page author omits `secure`, so the prop defaults to `false` and `PinFieldComponent` renders each entered digit as text; nearby observers can read the PIN on screen.
Default PIN input to masked and require an explicit opt-out to display digits.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:299-322`.
</issue_to_address>
### Comment 2
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="307" />
<code_context>
+ onValueChange = { update(it.text) },
+ enabled = scope.get<Boolean>("enabled") ?: true,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+ modifier = scope.modifier(clickable = false).focusRequester(focusRequester).onFocusChanged { focused = it.isFocused },
</code_context>
<issue_to_address>
**Accessibility exposes PIN digits**
When secure is enabled and an accessibility service reads the focused field, the hidden BasicTextField keeps the raw PIN without a password visual transformation, so its text semantics expose the digits to accessibility services.
Apply password masking to the BasicTextField's visual transformation or semantics when secure is enabled.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:310`.
</issue_to_address>
### Comment 3
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="340" />
<code_context>
+ val selected = scope.get<String>("value")
+ val enabled = scope.get<Boolean>("enabled") ?: true
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (enabled) expanded = it }, modifier = scope.modifier(clickable = false)) {
+ OutlinedTextField(
+ value = options.firstOrNull { it.first == selected }?.second ?: "",
</code_context>
<issue_to_address>
**Disabled Select remains actionable**
When enabled changes to false while the Select menu is open, the expanded state remains true and the menu items remain enabled, so users can select an option and dispatch onSelect after the control is disabled.
Close the menu when enabled becomes false and prevent its items from selecting options while disabled.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:354`.
</issue_to_address>
### Comment 4
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="261" />
<code_context>
- visualTransformation = if (keyboard == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None,
- modifier = scope.modifier(clickable = false).focusRequester(focusRequester).onFocusChanged { if (!it.isFocused) commit() },
+ keyboardOptions = KeyboardOptions(keyboardType = keyboard, imeAction = if (next) ImeAction.Next else ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { commit() }, onNext = { commit(); focusManager.moveFocus(FocusDirection.Next) }),
+ visualTransformation = if (secure) PasswordVisualTransformation() else VisualTransformation.None,
+ modifier = scope.modifier(clickable = false).focusRequester(focusRequester).onFocusChanged {
</code_context>
<issue_to_address>
**Next sends duplicate commits**
When the user presses Next and focus moves to another focusable field, keyboardActions calls commit() before moving focus, then the focus-loss handler calls commit() again, so the page receives two onCommit events for one Next action.
Ensure the Next action and focus-loss handler cannot both commit the same field interaction.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:268-269`.
</issue_to_address>
### Comment 5
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="218" />
<code_context>
@Composable
private fun TextFieldComponent(scope: NodeScope) {
- var text by remember { mutableStateOf(scope.get<String>("initialText") ?: "") }
+ val maxLength = scope.get<Double>("maxLength")?.toInt()?.takeIf { it > 0 }
+ fun clip(t: String) = if (maxLength != null && t.length > maxLength) t.take(maxLength) else t
+ var text by remember { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
</code_context>
<issue_to_address>
**Existing input exceeds new limit**
When a page lowers maxLength or length below the input already held by a mounted field, textField and PinField keep their existing local state without normalizing it to the new limit, so text or PIN digits beyond the limit remain stored and displayed, and PIN digits can reappear if length grows again.
Normalize each field's remembered input whenever its limit prop changes.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:219-220`, `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:279`.
</issue_to_address>
### Comment 6
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="289-290" />
<code_context>
+ val changed = t != value.text
+ value = TextFieldValue(t, selection = TextRange(t.length))
+ if (!changed) return
+ if (scope.has("onChange")) scope.dispatch("onChange", payload("text", t))
+ if (t.length == length && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t))
+ }
+ scope.Commands { command ->
</code_context>
<issue_to_address>
**Callbacks arrive out of order**
When multiple callbacks are dispatched in quick succession and their entry coroutines acquire the runtime in the opposite order, pageHost.dispatch launches each event entry separately, so PIN onComplete can run before onChange and focus-loss onCommit can run before onFocusChange; page state can end up inconsistent.
Serialize event-entry dispatch so callbacks reach the runtime in the order they were dispatched.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:267-268`.
</issue_to_address>
### Comment 7
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="279" />
<code_context>
+ */
+@Composable
+private fun PinFieldComponent(scope: NodeScope) {
+ val length = (scope.get<Double>("length")?.toInt() ?: 6).coerceIn(1, 12)
+ var value by remember { mutableStateOf(TextFieldValue("")) }
+ val focusRequester = remember { FocusRequester() }
</code_context>
<issue_to_address>
**Long PINs are truncated**
When a caller configures PinField with length greater than 12, pinFieldComponent clamps length to 12, so the field renders at most twelve cells and update discards input beyond the twelfth digit.
Support the requested PIN length instead of clamping it to 12.
</issue_to_address>
### Comment 8
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="288" />
<code_context>
+ val t = raw.filter { it in '0'..'9' }.take(length)
+ val changed = t != value.text
+ value = TextFieldValue(t, selection = TextRange(t.length))
+ if (!changed) return
+ if (scope.has("onChange")) scope.dispatch("onChange", payload("text", t))
+ if (t.length == length && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t))
</code_context>
<issue_to_address>
**Unchanged PIN commands emit no event**
When clear is called on an empty PIN or setText supplies the current value, update returns before dispatching onChange when the filtered text equals the current value, so the page receives no event for these commands despite the documented command behavior.
Dispatch onChange for setText and clear commands even when the resulting PIN is unchanged.
</issue_to_address>
### Comment 9
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="227" />
<code_context>
scope.Commands { command ->
when (command.name) {
- "setText" -> { text = command.args["text"] as? String ?: ""; if (scope.has("onChange")) scope.dispatch("onChange", payload("text", text)) }
+ "setText" -> { text = clip(command.args["text"] as? String ?: ""); if (scope.has("onChange")) scope.dispatch("onChange", payload("text", text)) }
"focus" -> focusRequester.requestFocus()
"blur" -> focusManager.clearFocus()
</code_context>
<issue_to_address>
**Commands use stale limits**
When `maxLength` or `length` changes while a field remains mounted and a `setText` command arrives afterward, `NodeScope.Commands` runs its handler in a `LaunchedEffect` keyed only by the node, so its first handler keeps the original `maxLength` or `length` closure. Later `setText` commands therefore truncate using the old limit, and callers observe values inconsistent with the current props.
Have `NodeScope.Commands` invoke the latest handler, for example by wrapping it in `rememberUpdatedState`.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:294`.
</issue_to_address>
### Comment 10
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="219" />
<code_context>
private fun TextFieldComponent(scope: NodeScope) {
- var text by remember { mutableStateOf(scope.get<String>("initialText") ?: "") }
+ val maxLength = scope.get<Double>("maxLength")?.toInt()?.takeIf { it > 0 }
+ fun clip(t: String) = if (maxLength != null && t.length > maxLength) t.take(maxLength) else t
+ var text by remember { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
+ var focused by remember { mutableStateOf(false) }
</code_context>
<issue_to_address>
**Length limit splits emoji**
When `maxLength` truncates input immediately after the first UTF-16 code unit of a supplementary character, such as an emoji, `clip` uses `String.take`, which can cut between a surrogate pair; the field then stores and reports an unpaired surrogate instead of the entered character, so users see corrupted text and callers receive corrupted input.
Truncate at a Unicode code-point boundary, or explicitly define and implement a length unit that cannot split a character.
</issue_to_address>
### Comment 11
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="319" />
<code_context>
+ val outline = when { error -> colors.error; current -> colors.primary; else -> colors.outline }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.weight(1f).height(56.dp).border(if (current || error) 2.dp else 1.dp, outline, RoundedCornerShape(8.dp)),
+ ) {
+ if (digit != null) Text(if (secure) "\u2022" else digit.toString(), style = MaterialTheme.typography.headlineSmall)
</code_context>
<issue_to_address>
**PIN cells collapse in unbounded rows**
When pinField is placed inside a horizontally scrolling Row, the cell boxes get their width only from `weight`; Compose ignores Row weights when horizontal constraints are unbounded, so a PinField inside a horizontally scrolling Row renders cells without usable widths.
Give PIN cells a nonzero base width or otherwise size the cell row so it works under unbounded horizontal constraints.
</issue_to_address>Sourcery assessment
Approval pending. 11 findings to address first.
Blocking findings: schema/components/pin-field.ts:7, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:307, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:340, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:261, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:218, and 6 more
- Android:semantics contentType = SmsOtpCode - iOS:Compose 只从 PlatformImeOptions 读 textContentType,不设时按 NumberPassword 推成 Password(交易密码会弹钥匙串); 所以两种取值都显式设置:oneTimeCode → UITextContentTypeOneTimeCode,none → null - sample form 页加一个短信验证码示例;components.md 同步 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 5 条建议)
上轮意见:5 条,已处理 0 条,待处理 5 条
compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:265[建议]onNext先调用一次commit(),随后moveFocus(FocusDirection.Next)成功时会触发第 2…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:224[建议]text的remember没有以maxLength为 key,且裁剪只发生在初始化、用户输入和setText命令…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:292[建议]update在文本未变化时直接返回,所以对已为空的 PinField 执行clear,或用相同内容执行setText,都不会…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:284[建议]length是可更新的普通 prop,但value只在首次组合时创建;当页面把length从较大值改小,已有的 `val…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:360[建议] 下拉菜单已展开后若页面将enabled更新为false,expanded不会被关闭,而这里的 `DropdownMenuI…
摘要
本次增量为 PinField 增加了 autofill 配置,并在 Android 通过 SmsOtpCode 语义、iOS 通过 PlatformImeOptions 设置一次性验证码内容类型。生成的 Kotlin/TypeScript schema、文档和示例页均已同步该属性。根据给出的代码,未发现本次增量引入的可确认问题。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 6 条建议)
上轮意见:5 条,已处理 0 条,待处理 5 条
compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:265[建议]onNext先调用一次commit(),随后moveFocus(FocusDirection.Next)成功时会触发第 2…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:224[建议]text的remember没有以maxLength为 key,且裁剪只发生在初始化、用户输入和setText命令…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:292[建议]update在文本未变化时直接返回,所以对已为空的 PinField 执行clear,或用相同内容执行setText,都不会…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:360[建议] 下拉菜单已展开后若页面将enabled更新为false,expanded不会被关闭,而这里的 `DropdownMenuI…compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:284[建议]length是可更新的普通 prop,但value只在首次组合时创建;当页面把length从较大值改小,已有的 `val…
摘要
本次增量将 PinField 统一更名为 CodeField,并同步更新了 schema、Compose 注册、生成的 TS/Kotlin API、运行时字节码、文档和示例。组件实现逻辑基本未变,因此上轮关于动态 length 与内部输入状态不同步的风险仍然存在。
- PageHost:入口协程走单通道再抢引擎锁,事件不再在多线程池里乱序(新增回归测试,修前 500 个事件必乱) - Commands:rememberUpdatedState,命令处理器不再捕获首帧的 props(maxLength / length 变了后 setText 用旧上限) - TextField:imeAction next 只经失焦提交一次 onCommit;maxLength 调小时立即裁剪并报 onChange;截断不切开代理对 - CodeField:setText / clear 无变化也报 onChange(与文档、TextField 一致);length 调小时立即裁剪;secure 时隐藏输入框也做掩码、格子不再重复暴露给读屏;格子最小宽 40dp,滚动 Row 里不塌;length 文档写明 1–12 - Select:展开中被禁用时收起菜单,选项随 enabled 禁用 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
一个宿主的注册流程逼出的五项内置组件(M1 第一部分;http tags / crypto.rsaEncrypt / host 保留包名、PageHost 随导航条目存活另起 PR,合齐后发 0.14.0)。
改动
Checkboxchecked(必填)、enabled;onClick。与RadioButton同模式TextFieldsecure/error/supportingText/prefixText/trailingIcon/imeAction/maxLength;新事件onFocusChange/onTrailingClick/onPrefixClick。修:首帧未聚焦不再误发onCommitCodeFieldlength/secure/enabled/error/autofill(none / oneTimeCode);onChange/onComplete;命令setText/focus/clear。Compose / M3 无对应组件,框架自绘;短信验证码与交易密码共用,故不叫 Pin / OtpSelect+OptionOptionchildren;选中值 prop 叫value(selected已是公共布局 prop)TextonLinkClick{id},给了时[文案](id)渲染成链接prefixText画在 leading 槽而非 M3prefix:后者空且未聚焦时隐藏、不能点autofill:Android 走 semanticsSmsOtpCode;iOS 只读PlatformImeOptions,两种取值都显式设(不设时NumberPassword会被推成 Password、弹钥匙串)。真机短信建议待 M3 接真实发码后验CodeField的clear/setText与TextField.setText一样触发onChange,components.md 写了「输错 → 清空并标红」的正确写法验证
pnpm schema:check/runtime:check/typecheck/test,:compose:testAndroidHostTest(新增LinkedTextTest)、:compose:apiCheck、iOS 编译、:sample:shared:testAndroidHostTestform页,在 Android 模拟器(Pixel 10)和 iOS 模拟器(iPhone 18 Pro)上逐项点过:邮箱报错态、imeAction next、聚焦提示、显隐切换、区号点击、整行勾选、链接不误触勾选、下拉选择、验证码框 / 交易密码输错标红清空 / 输对完成🤖 Generated with Claude Code
Summary by Sourcery
Expand the built-in component set and input behaviors to support complete registration forms.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: