diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..01fd9bb 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -14,11 +14,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -79,6 +81,7 @@ object BareTextInputRenderer { var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } var wasFocused by remember { mutableStateOf(false) } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) // Caret / selection reporter — independent of the direct change/submit // dispatchers; no-op unless `on_selection_change` is wired and the @@ -125,6 +128,7 @@ object BareTextInputRenderer { // flush the pending selection on the focused → unfocused edge. modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .onFocusChanged { state -> if (wasFocused && !state.isFocused) selectionReporter.flush(value) wasFocused = state.isFocused @@ -160,6 +164,19 @@ object BareTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) props.dispatchSubmit?.invoke(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) + } }) ) } diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0231c9c..44b9fb5 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -19,10 +19,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -79,6 +81,7 @@ object FilledTextInputRenderer { } val interactionSource = remember { MutableInteractionSource() } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -124,7 +127,7 @@ object FilledTextInputRenderer { // Full width by default (parity with the iOS renderer's // maxWidth: .infinity); an explicit width in `modifier` (FIXED // layout mode) still wins since it comes later in the chain. - modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, @@ -143,10 +146,26 @@ object FilledTextInputRenderer { minLines = props.minLines, visualTransformation = props.visualTransformation, keyboardOptions = keyboardOptionsFor(props), - keyboardActions = KeyboardActions(onDone = { + // onAny, not onDone: `submit-label` can make the IME action Next / + // Go / Search / Send, and an onDone-only handler would silently + // drop the submit for those. Matches the bare renderer. + keyboardActions = KeyboardActions(onAny = { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = TextFieldDefaults.colors( diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 4013484..9d7d983 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -19,10 +19,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -91,6 +93,7 @@ object OutlinedTextInputRenderer { // modes. Passing our own source also means we don't pay for M3's // default ripple-focus-hover machinery elsewhere. val interactionSource = remember { MutableInteractionSource() } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -137,7 +140,7 @@ object OutlinedTextInputRenderer { // Full width by default (parity with the iOS renderer's // maxWidth: .infinity); an explicit width in `modifier` (FIXED // layout mode) still wins since it comes later in the chain. - modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, @@ -156,10 +159,26 @@ object OutlinedTextInputRenderer { minLines = props.minLines, visualTransformation = props.visualTransformation, keyboardOptions = keyboardOptionsFor(props), - keyboardActions = KeyboardActions(onDone = { + // onAny, not onDone: `submit-label` can make the IME action Next / + // Go / Search / Send, and an onDone-only handler would silently + // drop the submit for those. Matches the bare renderer. + keyboardActions = KeyboardActions(onAny = { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = OutlinedTextFieldDefaults.colors( diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 50173f9..c52813d 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -3,10 +3,15 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -52,6 +57,10 @@ internal data class TextInputProps( val maxLength: Int, val keyboard: KeyboardType, val capitalization: KeyboardCapitalization?, + val submitLabel: String, + val focusRef: String, + val nextFocus: String, + val autofocus: Boolean, val disabled: Boolean, val readOnly: Boolean, val isError: Boolean, @@ -116,6 +125,10 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { maxLength = p.getInt("max_length"), keyboard = resolveKeyboardType(p.getString("keyboard")), capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getBool("secure"), p.getString("keyboard")), + submitLabel = p.getString("submit_label"), + focusRef = p.getString("focus_ref"), + nextFocus = p.getString("next_focus"), + autofocus = p.getBool("autofocus"), disabled = p.getBool("disabled"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), @@ -212,10 +225,125 @@ internal fun resolveCapitalization(explicit: String, secure: Boolean, keyboard: } } -internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions = - props.capitalization - ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it) } - ?: KeyboardOptions(keyboardType = props.keyboard) +/** + * IME action for the submit key — the `submit_label` prop. The explicit + * value wins; unset — or unknown, same policy as [resolveKeyboardType] — + * derives [ImeAction.Next] when a `next_focus` chain is set (mirroring how + * capitalization derives from the keyboard type), else keeps + * [ImeAction.Default], i.e. exactly the pre-prop behaviour. + * + * "return" is iOS vocabulary (a plain Return key); Android has no exact + * equivalent, so it resolves to the IME default — listed explicitly so an + * author's `return` beats the `next_focus` derivation. + * + * A multiline field ignores both props entirely: a non-default IME action + * replaces the return key, and multiline's return key must keep inserting + * newlines. iOS ignores them for multiline the same way + * (`resolveSubmitLabel` in `NativeUITextInputCore.swift`) — keep the two + * in sync. + */ +internal fun resolveImeAction(explicit: String, multiline: Boolean, nextFocus: String): ImeAction { + if (multiline) return ImeAction.Default + + return when (explicit.lowercase()) { + "next" -> ImeAction.Next + "done" -> ImeAction.Done + "go" -> ImeAction.Go + "search" -> ImeAction.Search + "send" -> ImeAction.Send + "return" -> ImeAction.Default + else -> if (nextFocus.isNotEmpty()) ImeAction.Next else ImeAction.Default + } +} + +internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions { + val imeAction = resolveImeAction(props.submitLabel, props.multiline, props.nextFocus) + + return props.capitalization + ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it, imeAction = imeAction) } + ?: KeyboardOptions(keyboardType = props.keyboard, imeAction = imeAction) +} + +/** + * Screen-wide focus routing for text inputs — the `next-focus` prop. + * + * Each renderer whose element carries a `ref` registers its + * [FocusRequester] under that name (a `DisposableEffect` keyed on the + * ref), and the submit handler of a field with `next_focus` set asks the + * registry to move the keyboard there. Focus is only ever requested from + * a user-initiated submit on another field — never from server pushes — + * so the registry cannot steal focus spontaneously. + * + * Main-thread only: registration happens in composition effects and + * requests in IME action handlers. Last registration wins (refs are + * unique per screen by convention — they are the same refs + * `Native::test()` targets); unregistration is identity-guarded so a + * disposed screen can't tear down the ref it was shadowing. iOS keeps the + * same contract (`NativeUIFocusRegistry.swift`) — keep the two in sync. + */ +internal object NativeUIFocusRegistry { + private val entries = mutableMapOf() + + fun register(ref: String, requester: FocusRequester) { + entries[ref] = requester + } + + fun unregister(ref: String, requester: FocusRequester) { + if (entries[ref] === requester) { + entries.remove(ref) + } + } + + /** + * Focus the field registered under [ref]. Returns whether a live + * target existed — a missing or detached target (off-screen, recycled + * row, typo'd ref) is a no-op, never a crash. + */ + fun request(ref: String): Boolean { + val requester = entries[ref] ?: return false + return try { + requester.requestFocus() + true + } catch (_: IllegalStateException) { + // Registered but no longer attached to a composed node (the + // row was recycled between registration and this submit). + false + } + } +} + +/** + * Remember a [FocusRequester] kept registered in [NativeUIFocusRegistry] + * under the field's `focus_ref` while it is in composition. Hang the + * result on the field's `Modifier.focusRequester`; an empty ref returns a + * plain, unregistered requester. + */ +@Composable +internal fun rememberRegisteredFocusRequester(focusRef: String, autofocus: Boolean = false): FocusRequester { + val requester = remember { FocusRequester() } + DisposableEffect(focusRef) { + if (focusRef.isNotEmpty()) { + NativeUIFocusRegistry.register(focusRef, requester) + } + onDispose { + if (focusRef.isNotEmpty()) { + NativeUIFocusRegistry.unregister(focusRef, requester) + } + } + } + // `autofocus`: focus (and raise the IME on) the field the user came to + // fill, once per composition — a re-render that moves the attribute to + // an already-composed field never steals focus mid-edit. The short + // delay lets the host (screen push, bottom sheet) finish laying out; + // requesting focus on an unplaced node is silently dropped. + if (autofocus) { + LaunchedEffect(Unit) { + delay(200) + runCatching { requester.requestFocus() } + } + } + return requester +} /** * Outbound dispatch state machine. Call [onTextChanged] whenever local text diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index 3a2743f..e3b6207 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -30,13 +30,21 @@ struct NativeUIBottomSheetRenderer: View { NativeUIBridge.sendSheetDismissEvent(onDismissCb, nodeId: node.id) } }) { - VStack(spacing: 0) { - ForEach(node.children) { child in - NodeView(node: child).equatable() + // The sheet hosts its own keyboard accessory bar — the + // screen-root host's bar sits BEHIND a presented sheet, so + // fields inside the sheet publish to this copy instead + // (`sheetDepth` makes the root host yield while we're up). + NativeUIKeyboardAccessoryHost { + VStack(spacing: 0) { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(theme.surface) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(theme.surface) + .onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 } + .onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 } .presentationDetents(resolveDetents(detentsStr)) .presentationDragIndicator(.visible) .interactiveDismissDisabled(permanent) diff --git a/resources/ios/NativeUIDrawerHost.swift b/resources/ios/NativeUIDrawerHost.swift index edfa3ae..eb0d753 100644 --- a/resources/ios/NativeUIDrawerHost.swift +++ b/resources/ios/NativeUIDrawerHost.swift @@ -21,6 +21,14 @@ func registerNativeUIChrome() { return AnyView(NativeUIBackgroundLayerHost(layerNode: layerNode) { content }) } + // Keyboard accessory bar for pad-keyboard text inputs on plain screens + // (fields inside a bottom sheet use the sheet renderer's own host — a + // root-level bar would sit behind the sheet). No sentinel element: + // the host is driven by the shared focus state, not the tree. + NativeRootHostRegistry.shared.register("native-ui.keyboard-accessory") { _, content in + AnyView(NativeUIKeyboardAccessoryHost(hidesUnderSheets: true) { content }) + } + // Resolve chrome font tokens (per-layout / per-bar `font_name` props on // the root sentinels) for core's chrome renderers — bundle lookup + // CoreText registration + PostScript naming is this plugin's knowledge. diff --git a/resources/ios/NativeUIFocusRegistry.swift b/resources/ios/NativeUIFocusRegistry.swift new file mode 100644 index 0000000..d7c1b27 --- /dev/null +++ b/resources/ios/NativeUIFocusRegistry.swift @@ -0,0 +1,78 @@ +import Foundation +import UIKit + +/// Screen-wide focus routing for text inputs — the `next-focus` prop. +/// +/// Each text input core whose element carries a `ref` registers a closure +/// that asserts its own `@FocusState` (keyed by that ref) on appear, and +/// unregisters on disappear. A submitting field with `next_focus` set asks +/// the registry to run the target's closure, moving the keyboard to that +/// field without any cross-view focus state or ancestor restructuring. +/// +/// Main-thread only: every caller is a SwiftUI lifecycle hook or submit +/// handler. Focus is only ever requested from a user-initiated submit on +/// another field — never from server pushes — so the registry cannot steal +/// focus spontaneously. +/// +/// Last registration wins: refs are unique per screen by convention (they +/// are the same refs `Native::test()` targets), but two live screens on a +/// navigation stack may reuse one — the most recently appeared field then +/// receives the focus, which is the visible one anyway. Unregistration is +/// token-guarded so a disappearing pushed screen can't tear down the ref +/// it was shadowing. +final class NativeUIFocusRegistry { + static let shared = NativeUIFocusRegistry() + + private struct Entry { + let token: UUID + let focus: () -> Void + weak var backingField: UITextField? + } + + private var entries: [String: Entry] = [:] + + /// Register `focus` under `ref`, replacing any previous registration. + /// Returns the token `unregister` needs. + func register(_ ref: String, focus: @escaping () -> Void) -> UUID { + let token = UUID() + entries[ref] = Entry(token: token, focus: focus) + return token + } + + /// Remove the registration for `ref`, but only while it is still the + /// one identified by `token` — a later registration under the same ref + /// must survive its predecessor's disappearance. + func unregister(_ ref: String, token: UUID) { + if entries[ref]?.token == token { + entries[ref] = nil + } + } + + /// Attach the UIKit text field backing the SwiftUI field registered + /// under `ref` (found by the return-key interceptor's introspection). + /// A hop can then be a direct responder handoff — the only transition + /// that keeps the keyboard perfectly still. Weak: the entry outlives + /// nothing; a recreated backing re-attaches on its next render. + func attachBackingField(_ ref: String, field: UITextField) { + entries[ref]?.backingField = field + } + + /// Focus the field registered under `ref`. Returns whether a target + /// existed — a missing target (off-screen, recycled row, typo'd ref) + /// is a no-op, never a crash. + /// + /// Prefers a UIKit responder handoff to the target's backing field: + /// with no dismissal queued (the return key was intercepted), UIKit + /// moves the keyboard between fields without any hide/show. The + /// SwiftUI FocusState closure is the fallback for fields whose + /// backing hasn't been introspected yet. + @discardableResult + func focus(_ ref: String) -> Bool { + guard let entry = entries[ref] else { return false } + if let tf = entry.backingField, tf.window != nil, tf.becomeFirstResponder() { + return true + } + entry.focus() + return true + } +} diff --git a/resources/ios/NativeUIKeyboardAccessoryBar.swift b/resources/ios/NativeUIKeyboardAccessoryBar.swift new file mode 100644 index 0000000..b50d2b2 --- /dev/null +++ b/resources/ios/NativeUIKeyboardAccessoryBar.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// The Next/Done affordance for pad-style keyboards (number / decimal / +/// phone), which have no return key of their own. +/// +/// This replaces a `.toolbar(placement: .keyboard)` implementation. iOS 26 +/// renders keyboard-placement toolbar items as a floating Liquid Glass +/// capsule sitting directly on the keyboard (and drops them from the +/// accessibility tree on 26.1), UIKit's `inputAccessoryView` no longer +/// attaches seamlessly to the redesigned keyboard, and SwiftUI never shows +/// keyboard toolbars inside a bare `.sheet` at all. Apple's own guidance for +/// the padding complaints is to show the affordance as a bottom safe-area +/// bar while the field is focused — which is exactly what this does: the +/// focused field publishes its submit affordance to the shared state below, +/// and a HOST (the bottom-sheet content root, or the screen root via +/// `NativeRootHostRegistry`) pins a full-width, theme-colored bar above the +/// keyboard with `.safeAreaInset(edge: .bottom)`. Keyboard avoidance keeps +/// it riding the keyboard's top edge on every iOS version, with no glass. +final class NativeUIKeyboardAccessoryState: ObservableObject { + static let shared = NativeUIKeyboardAccessoryState() + + /// The bar's visible state — nil hides it. Only identity and title are + /// published; the ACTION lives in `perform` (below, non-published) so + /// the focused field can refresh it on every render without triggering + /// view updates. It can genuinely change mid-focus: toggling the Yaniv + /// caller re-renders the focused field with a different `next_focus`. + struct Info: Equatable { + let id: UUID + let title: String + } + + @Published private(set) var info: Info? + + /// Presented bottom sheets. The screen-root host hides its bar while a + /// sheet is up — the sheet hosts its own copy, and the covered screen + /// shouldn't inset for a bar nobody can see. + @Published var sheetDepth: Int = 0 + + private var perform: () -> Void = {} + + /// Monotonic claim counter — a deferred release only lands when no + /// claim happened after it was scheduled (see `release`). + private var claimSeq = 0 + + /// Called when a field gains focus (or its accessory title changes). + func claim(id: UUID, title: String, perform: @escaping () -> Void) { + claimSeq += 1 + self.perform = perform + if info?.id != id || info?.title != title { + info = Info(id: id, title: title) + } + } + + /// Refresh the action from the focused field's body on every render. + /// The closure swap is silent; a title change republishes async (body + /// must not mutate published state synchronously). + func refresh(id: UUID, title: String, perform: @escaping () -> Void) { + guard info?.id == id else { return } + self.perform = perform + if info?.title != title { + DispatchQueue.main.async { [weak self] in + guard let self, self.info?.id == id else { return } + self.info = Info(id: id, title: title) + } + } + } + + /// Called on blur / disappear. Deferred one runloop and guarded both + /// ways: a field losing focus because ANOTHER field claimed the bar + /// must not tear the new claim down, and during a pad-keyboard hop + /// the release must not remove the bar for a frame between the old + /// field's blur and the new field's claim (a visible 44pt safe-area + /// jump that reads as a keyboard dip). + func release(id: UUID) { + let seq = claimSeq + DispatchQueue.main.async { [weak self] in + guard let self, self.claimSeq == seq, self.info?.id == id else { return } + self.info = nil + self.perform = {} + } + } + + func submit() { + perform() + } +} + +/// Wraps a content root and pins the accessory bar above the keyboard while +/// a field has claimed it. Transparent pass-through (no inset, no cost) +/// while no claim is active. +struct NativeUIKeyboardAccessoryHost: View { + /// Set on the screen-root instance — its bar yields while any bottom + /// sheet is presented (the sheet's own host takes over). + var hidesUnderSheets: Bool = false + @ViewBuilder var content: Content + + @ObservedObject private var state = NativeUIKeyboardAccessoryState.shared + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + content + .safeAreaInset(edge: .bottom, spacing: 0) { + if let info = state.info, !(hidesUnderSheets && state.sheetDepth > 0) { + let theme = themeStore.resolve(for: colorScheme) + HStack { + Spacer() + Button { + state.submit() + } label: { + Text(info.title) + .font(.body.weight(.semibold)) + .foregroundStyle(theme.primary) + // A generous hit target on a 44pt bar. + .padding(.horizontal, 4) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 44) + .background(theme.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(theme.outlineVariant) + .frame(height: 0.5) + } + } + } + } +} diff --git a/resources/ios/NativeUIReturnKeyInterceptor.swift b/resources/ios/NativeUIReturnKeyInterceptor.swift new file mode 100644 index 0000000..a19496f --- /dev/null +++ b/resources/ios/NativeUIReturnKeyInterceptor.swift @@ -0,0 +1,145 @@ +import SwiftUI +import UIKit + +/// Dip-free `next-focus` chaining, the way every production framework does +/// it (React Native, Flutter, Compose all converged here). +/// +/// SwiftUI queues the keyboard dismissal as part of handling the return +/// key ITSELF, before `.onSubmit` runs — and since iOS 17 the keyboard is +/// out-of-process, so a queued hide cannot be cancelled by anything the +/// submit handler does (focus writes, shared FocusState, even a UIKit +/// becomeFirstResponder — all tried, all dipped). The only way to keep +/// the keyboard still is to make sure a dismissal is NEVER queued: +/// intercept `textFieldShouldReturn` on the UIKit text field backing the +/// SwiftUI TextField, run the submit path ourselves, move first +/// responder straight to the target's backing field, and return `false` +/// — exactly React Native's `blurOnSubmit={false}` implementation. +/// +/// Wiring: `ReturnKeyInterceptorAnchor` rides in the field's +/// `.background`. From there it finds the backing `UITextField`, wraps +/// SwiftUI's delegate in `NativeUIReturnKeyProxy` (forwarding everything +/// except `textFieldShouldReturn`), registers the backing field with the +/// focus registry so hops become plain responder handoffs, and re-checks +/// the hook on every SwiftUI update (SwiftUI may reinstall its +/// delegate). Fields with no chain forward return-key handling to +/// SwiftUI untouched — stock submit + dismissal. + +/// Per-field mutable channel between the SwiftUI view (which recomputes +/// its submit closure and chain state every render) and the long-lived +/// UIKit proxy. Read at return-key time, so it can never go stale. +final class NativeUIReturnKeyBox { + /// Whether the return key should be intercepted (a `next-focus` + /// chain exists on a single-line field). + var chains = false + + /// The field's full submit path — flush, submit event, focus hop. + var perform: () -> Void = {} +} + +/// Forwarding delegate wrapper. Everything SwiftUI's coordinator +/// implements keeps working; only the return key is rerouted. +final class NativeUIReturnKeyProxy: NSObject, UITextFieldDelegate { + weak var original: UITextFieldDelegate? + var box: NativeUIReturnKeyBox? + + override func responds(to aSelector: Selector!) -> Bool { + super.responds(to: aSelector) || (original?.responds(to: aSelector) ?? false) + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + original + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if let box, box.chains { + box.perform() + // `false` = no editingDidEndOnExit, no SwiftUI submit + // machinery, and crucially NO queued keyboard dismissal. + return false + } + if let original, original.responds(to: #selector(UITextFieldDelegate.textFieldShouldReturn(_:))) { + return original.textFieldShouldReturn?(textField) ?? true + } + return true + } +} + +/// Invisible zero-size anchor placed in the field's `.background` — +/// the standard introspection seam between a SwiftUI view and its UIKit +/// backing. +struct ReturnKeyInterceptorAnchor: UIViewRepresentable { + let box: NativeUIReturnKeyBox + let focusRef: String + + func makeUIView(context: Context) -> IntrospectionView { + let view = IntrospectionView() + view.isUserInteractionEnabled = false + view.box = box + view.focusRef = focusRef + return view + } + + func updateUIView(_ view: IntrospectionView, context: Context) { + view.box = box + view.focusRef = focusRef + // Deferred: never mutate UIKit delegates mid-SwiftUI-update, and + // give SwiftUI a beat to (re)attach its own machinery first. + DispatchQueue.main.async { view.hook() } + } + + final class IntrospectionView: UIView { + var box: NativeUIReturnKeyBox? + var focusRef: String = "" + + private var proxy: NativeUIReturnKeyProxy? + private weak var hooked: UITextField? + + override func didMoveToWindow() { + super.didMoveToWindow() + DispatchQueue.main.async { [weak self] in self?.hook() } + } + + /// Find the backing field, install (or repair) the delegate + /// proxy, and register the backing field for direct-responder + /// focus hops. Idempotent; called on every SwiftUI update + /// because SwiftUI can reinstall its delegate at any render. + func hook() { + guard window != nil, let tf = findTextField() else { return } + + if !focusRef.isEmpty { + NativeUIFocusRegistry.shared.attachBackingField(focusRef, field: tf) + } + + let p = proxy ?? NativeUIReturnKeyProxy() + proxy = p + p.box = box + if tf.delegate !== p { + p.original = tf.delegate + tf.delegate = p + } + hooked = tf + } + + /// The anchor sits in the field's `.background`, so the nearest + /// UITextField above/beside us IS our field. Climb a few + /// ancestors, searching each level's subtree; the first (i.e. + /// closest) match wins, which keeps sibling rows out of reach. + private func findTextField() -> UITextField? { + var ancestor = superview + for _ in 0..<6 { + guard let a = ancestor else { return nil } + if let tf = Self.firstTextField(in: a) { return tf } + ancestor = a.superview + } + return nil + } + + private static func firstTextField(in view: UIView) -> UITextField? { + if let tf = view as? UITextField { return tf } + for sub in view.subviews { + if let tf = firstTextField(in: sub) { return tf } + } + return nil + } + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index fdd29d9..5f581da 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -48,6 +48,19 @@ struct NativeUITextInputCore: View { @State private var pendingSelection: NativeUISelectionPayload? = nil @State private var lastEmittedSelection: NativeUISelectionPayload? = nil + // Token for this field's `focus_ref` registration (see + // `NativeUIFocusRegistry`); nil when the element carries no ref. + @State private var focusRegistryToken: UUID? = nil + + // Stable identity of this field's claim on the shared keyboard + // accessory bar (pad keyboards only — see NativeUIKeyboardAccessoryBar). + @State private var accessoryToken = UUID() + + // Channel to the UIKit return-key interceptor (see + // NativeUIReturnKeyInterceptor.swift) — refreshed every render so the + // proxy always runs the CURRENT submit path and chain state. + @State private var returnKeyBox = NativeUIReturnKeyBox() + var body: some View { let p = node.props let placeholder = p.getString("placeholder") @@ -76,6 +89,12 @@ struct NativeUITextInputCore: View { let syncMode = p.getString("sync_mode", default: "live") let debounceMs = p.getInt("debounce_ms", default: 300) let keepFocus = p.getBool("keep_focus_on_submit") + let submitLabelKind = p.getString("submit_label") + // Focus chaining (`next-focus`): `focus_ref` is this field's own + // address in the focus registry (the element's `ref`, surfaced as a + // prop); `next_focus` is the ref to move the keyboard to on submit. + let focusRef = p.getString("focus_ref") + let nextFocus = p.getString("next_focus") // Selection reporting is opt-in (0/absent ⇒ off) and never applies to // secure fields. Read exactly like `on_change` / `debounce_ms` above. let onSelectionCb = p.getCallbackId("on_selection_change") @@ -94,6 +113,81 @@ struct NativeUITextInputCore: View { fontName: fontName ) + // One submit routine for both entry points: the keyboard's return key + // (`.onSubmit`) and the accessory-bar button below. Reading `text` / + // `isFocused` inside resolves the live @State values at call time. + let performSubmit = { + // Submit also acts as a commit point — flush pending, then dispatch. + flushPending(onChangeCb: onChangeCb) + // Selection is flushed BEFORE the submit event so PHP sees the final + // caret/selection state ahead of (or alongside) the submit. + if selectionEnabled { + flushSelection(cb: onSelectionCb) + } + if onSubmitCb != 0 { + NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) + } + // Focus routing after submit. `next-focus` wins over + // `keep-focus-on-submit` — moving the keyboard to the chained + // field IS keeping it up; keepFocus is only the fallback when the + // target isn't on screen (recycled row, conditional render, + // typo'd ref). + // + // The hop runs TWICE. Synchronously first: focus moving to the + // target inside the same transaction as the return key's resign + // reads as focus MOVING between fields, so the keyboard stays up + // instead of playing a down-and-back-up bounce (what UIKit's + // becomeFirstResponder-in-shouldReturn always did). Then again + // async as a safety net: on paths where the system's resign + // still wins after this handler returns, the re-assert restores + // focus exactly as the async-only version did — a bounce, but + // never a lost keyboard. Focusing an already-focused field is a + // no-op, so the second pass costs nothing when the first stuck. + if !nextFocus.isEmpty { + // With the return key intercepted (no dismissal queued), + // this is a plain responder handoff to the target's backing + // UITextField — the keyboard stays perfectly still. The + // async pass is the safety net for a target whose backing + // isn't introspected yet. + NativeUIFocusRegistry.shared.focus(nextFocus) + DispatchQueue.main.async { + if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { + isFocused = true + } + } + } else if keepFocus { + // Chat "send and keep typing": SwiftUI resigns first responder + // on return by default. Re-assert focus so the keyboard stays + // up. NOTE: this causes a small keyboard "bounce" on return + // (resign → refocus) that the send button doesn't have — the + // smooth fix needs a UIKit-backed field (see notes), not the + // multiline workaround which mis-sized the field in the flex + // layout. + DispatchQueue.main.async { isFocused = true } + } else { + // Accessory-button path: unlike the return key, tapping the + // bar doesn't resign first responder — dismiss explicitly so + // "Done" behaves like Done. No-op on the return-key path + // (focus is already gone by the time this runs). + isFocused = false + } + } + // Pad-style keyboards (number / decimal / phone) have NO return key, + // so the submit label, `next-focus` chain and `@submit` are physically + // unreachable from them. Surface the missing key as the shared + // accessory BAR above the keyboard (NativeUIKeyboardAccessoryBar): + // this field claims the bar while focused and hands it the same + // submit path as the return key. Refreshed from body every render so + // the action never goes stale while focused. + let padKeyboard = ["number", "decimal", "numberpassword", "phone"].contains(keyboardKind.lowercased()) + let wantsAccessory = padKeyboard && !multiline + && (onSubmitCb != 0 || !nextFocus.isEmpty || !submitLabelKind.isEmpty) + let accessoryTitle = accessoryButtonTitle( + explicit: submitLabelKind, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus + ) + let _ = refreshAccessoryClaim(wantsAccessory, title: accessoryTitle, perform: performSubmit) + let _ = syncReturnKeyBox(chains: !nextFocus.isEmpty && !multiline, perform: performSubmit) + // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't // reliably pick up `.foregroundStyle` for the input text on older @@ -144,6 +238,10 @@ struct NativeUITextInputCore: View { } } .nuiScaledFont(size: textSize, fontName: fontName.isEmpty ? nil : fontName) + // Invisible introspection anchor: finds the UIKit field backing + // this TextField, reroutes its return key through `returnKeyBox`, + // and registers the backing field for direct responder handoffs. + .background(ReturnKeyInterceptorAnchor(box: returnKeyBox, focusRef: focusRef)) // NOTE: SwiftUI's editable TextField ignores `.lineSpacing` for its // typed text (unlike `Text`), so `leading-*` has no visible effect on // iOS inputs. Kept for intent / forward-compat; leading works on @@ -154,13 +252,42 @@ struct NativeUITextInputCore: View { .textInputAutocapitalization(capitalization) .autocorrectionDisabled(!autocorrect) .disabled(disabled || readOnly) - .submitLabel(onSubmitCb != 0 ? .done : .return) + .submitLabel(resolveSubmitLabel(explicit: submitLabelKind, multiline: multiline, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus)) .onAppear { if !initialized { text = serverValue lastSentValue = serverValue initialized = true } + // Make this field focus-addressable. Capturing the FocusState + // binding keeps the registry free of any view reference. This + // closure is the fallback hop — the registry prefers a direct + // responder handoff to the backing UITextField attached by the + // return-key interceptor, which is what keeps the keyboard up. + if !focusRef.isEmpty { + let binding = $isFocused + focusRegistryToken = NativeUIFocusRegistry.shared.register(focusRef) { + binding.wrappedValue = true + } + } + // `autofocus`: raise the keyboard on the field the user came to + // fill. Fires per appearance (a fresh sheet presentation is a + // fresh appearance); a re-render that moves the prop to an + // already-mounted field never steals focus. The delay lets a + // presenting sheet's animation settle — focusing mid-transition + // is silently dropped by SwiftUI. + if p.getBool("autofocus") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isFocused = true + } + } + } + .onDisappear { + if let token = focusRegistryToken, !focusRef.isEmpty { + NativeUIFocusRegistry.shared.unregister(focusRef, token: token) + focusRegistryToken = nil + } + NativeUIKeyboardAccessoryState.shared.release(id: accessoryToken) } .onChange(of: serverValue) { _, newServerValue in // Only sync from server when the incoming value differs from what @@ -215,42 +342,48 @@ struct NativeUITextInputCore: View { scheduleSelectionEmit(text: text, cb: onSelectionCb, debounceMs: selDebounceMs) } .onChange(of: isFocused) { _, focused in + if focused { + if wantsAccessory { + NativeUIKeyboardAccessoryState.shared.claim( + id: accessoryToken, title: accessoryTitle, perform: performSubmit + ) + } + return + } // On blur, flush any pending change — covers both `blur` mode // (never dispatched mid-typing) and `debounce` mode (in-flight // timer that should commit immediately rather than race with // focus loss / keyboard dismiss). - if !focused { - flushPending(onChangeCb: onChangeCb) - // Flush any coalesced selection emit immediately on blur so the - // final caret state isn't stranded in the debounce window. - if selectionEnabled { - flushSelection(cb: onSelectionCb) - } - } - } - .onSubmit { - // Submit also acts as a commit point — flush pending, then dispatch. flushPending(onChangeCb: onChangeCb) - // Selection is flushed BEFORE the submit event so PHP sees the final - // caret/selection state ahead of (or alongside) the submit. + // Flush any coalesced selection emit immediately on blur so the + // final caret state isn't stranded in the debounce window. if selectionEnabled { flushSelection(cb: onSelectionCb) } - if onSubmitCb != 0 { - NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) - } - // Chat "send and keep typing": SwiftUI resigns first responder on - // return by default. Re-assert focus so the keyboard stays up. NOTE: - // this causes a small keyboard "bounce" on return (resign → refocus) - // that the send button doesn't have — the smooth fix needs a - // UIKit-backed field (see notes), not the multiline workaround which - // mis-sized the field in the flex layout. - if keepFocus { - DispatchQueue.main.async { isFocused = true } - } + NativeUIKeyboardAccessoryState.shared.release(id: accessoryToken) + } + .onSubmit { + performSubmit() } } + /// Body-time refresh of the return-key interceptor's channel — the + /// proxy reads it at key-press time, so the submit closure and chain + /// state can never go stale under republishes. + private func syncReturnKeyBox(chains: Bool, perform: @escaping () -> Void) { + returnKeyBox.chains = chains + returnKeyBox.perform = perform + } + + /// Body-time refresh of this field's accessory-bar claim — keeps the + /// bar's action and title current while focused (props can change under + /// a focused field when PHP republishes). No-op unless focused and + /// accessory-worthy; the state object defers any published change. + private func refreshAccessoryClaim(_ wants: Bool, title: String, perform: @escaping () -> Void) { + guard wants, isFocused else { return } + NativeUIKeyboardAccessoryState.shared.refresh(id: accessoryToken, title: title, perform: perform) + } + // ─── Dispatch policy ───────────────────────────────────────────────────── private func handleLocalChange(_ value: String, mode: String, debounceMs: Int, onChangeCb: Int) { @@ -424,6 +557,51 @@ private struct NativeUISelectionPayload: Equatable { let end: Int } +/// Submit-key face for the field. The explicit `submit_label` prop wins; +/// unset — or unknown, same policy as `resolveKeyboardType` — derives +/// `.next` when a `next_focus` chain is set (mirroring how capitalization +/// derives from the keyboard type), else keeps the original default: +/// `.done` when `@submit` is wired, `.return` otherwise. +/// +/// A multiline field ignores the prop entirely: on the vertical-axis +/// TextField a non-return submit label swaps newline insertion for a submit +/// action, silently taking away the field's reason to be multiline. Android +/// ignores the prop for multiline the same way (`resolveImeAction` in +/// `TextInputShared.kt`) — keep the two in sync. +private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bool, nextFocus: String) -> SubmitLabel { + if !multiline { + switch explicit.lowercased() { + case "next": return .next + case "done": return .done + case "go": return .go + case "search": return .search + case "send": return .send + case "return": return .return + default: break + } + if !nextFocus.isEmpty { return .next } + } + return hasSubmit ? .done : .return +} + +/// Title for the keyboard accessory button shown above pad-style keyboards +/// (which have no return key to carry the submit label). Same precedence as +/// `resolveSubmitLabel`: the explicit prop wins, then a `next_focus` chain +/// implies "Next", then `@submit` implies "Done". +private func accessoryButtonTitle(explicit: String, hasSubmit: Bool, nextFocus: String) -> String { + switch explicit.lowercased() { + case "next": return "Next" + case "done": return "Done" + case "go": return "Go" + case "search": return "Search" + case "send": return "Send" + case "return": return "Done" + default: break + } + if !nextFocus.isEmpty { return "Next" } + return "Done" +} + /// Keyboard resolution — accepts string hints ("email", "number", etc.) that /// map to UIKeyboardType. Unknown/empty falls through to default. private func resolveKeyboardType(_ kind: String) -> UIKeyboardType { diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 6cbb6bc..db5b322 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -2,6 +2,7 @@ namespace Native\Mobile\UI\Elements; +use InvalidArgumentException; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; use Native\Mobile\Icon\AndroidSymbol; @@ -19,7 +20,7 @@ * Allowed per-instance: * - `value`, `placeholder`, `label`, `supporting` (content) * - `disabled`, `readOnly`, `error`, `loading` (state) - * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior) + * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines`, `submit-label`, `next-focus` (behavior) * - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations) * - `size` (sm | md | lg) * - `a11y-label`, `a11y-hint` (accessibility) @@ -101,6 +102,15 @@ public function applyAttributes(array $attrs): void if (! empty($attrs['keepFocusOnSubmit']) || ! empty($attrs['keep-focus-on-submit']) || ! empty($attrs['keep-focus'])) { $this->keepFocusOnSubmit(); } + if (! empty($attrs['autofocus'])) { + $this->autofocus(); + } + if (isset($attrs['submit-label']) || isset($attrs['submitLabel'])) { + $this->submitLabel((string) ($attrs['submit-label'] ?? $attrs['submitLabel'])); + } + if (isset($attrs['next-focus']) || isset($attrs['nextFocus'])) { + $this->nextFocus((string) ($attrs['next-focus'] ?? $attrs['nextFocus'])); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -322,6 +332,91 @@ public function keepFocusOnSubmit(bool $value = true): static return $this; } + /** + * Which action the keyboard's submit key advertises — "next" | "done" | + * "go" | "search" | "send" | "return". Maps to SwiftUI's `SubmitLabel` + * on iOS and the IME action on Android. + * + * Leave it unset and each platform keeps its current default (iOS shows + * Done when `@submit` is wired, Return otherwise; Android leaves the IME + * action to the platform). The label is purely cosmetic — pressing the + * key still fires `@submit` and commits per `sync_mode`, whatever face + * it shows. + * + * "return" is iOS vocabulary (a plain Return key); Android has no exact + * equivalent and renders its IME default for it. + * + * IGNORED on a `multiline()` field natively, on both platforms: there + * the return key must keep inserting newlines, and a non-return submit + * label would silently replace that. Not validated here because the + * fluent order (`multiline()` before or after `submitLabel()`) must not + * change the outcome. + * + * Blade: `submit-label` (or `submitLabel`). + */ + public function submitLabel(string $label): static + { + $label = strtolower(trim($label)); + + if (! in_array($label, ['next', 'done', 'go', 'search', 'send', 'return'], true)) { + throw new InvalidArgumentException( + "Unknown submit-label `{$label}`. " + .'Use one of: next, done, go, search, send, return — or omit the attribute to keep the platform default.' + ); + } + + $this->inputProps['submit_label'] = $label; + + return $this; + } + + /** + * Move the keyboard focus to another text input when this one is + * submitted — the "Next" affordance of a multi-field form. `$ref` is + * the target input's `ref` (the same ref `Native::test()` targets); + * the chain is explicit, one hop per field. + * + * When set (and `submitLabel()` isn't), the renderers derive a `next` + * submit label, mirroring how capitalization derives from `keyboard`. + * A missing target at submit time (recycled list row, conditional + * render, typo) is a no-op — focus then follows `keep-focus-on-submit` + * or the platform default. `@submit` still fires first, and the field + * being left commits per `sync_mode` on losing focus, so + * `native:model.blur` bindings see the value before the hop. + * + * An empty ref is treated as unset so Blade can pass a conditional + * (`next-focus="{{ $next }}"`) without special-casing the last field. + * + * Blade: `next-focus` (or `nextFocus`). + */ + /** + * Focus this input (and raise the keyboard) when it first appears — + * the opening field of a form the user came here to fill. Fires once + * per appearance, only on mount: a re-render that moves the attribute + * to an already-mounted field never steals focus mid-edit. + * + * Blade: `autofocus` / `:autofocus="$bool"`. + */ + public function autofocus(bool $value = true): static + { + if ($value) { + $this->inputProps['autofocus'] = true; + } + + return $this; + } + + public function nextFocus(string $ref): static + { + $ref = trim($ref); + + if ($ref !== '') { + $this->inputProps['next_focus'] = $ref; + } + + return $this; + } + public function maxLines(int $lines): static { $this->inputProps['max_lines'] = $lines; @@ -488,6 +583,14 @@ protected function resolveProps(CallbackRegistry $registry): array { $props = $this->inputProps; + // A field is focus-addressable when its element carries a `ref` — + // surfaced to the renderers as a prop, because the node-level ref + // is not decoded natively. Each platform's focus registry keys the + // field under this name; another input's `next_focus` targets it. + if ($this->elementRef !== null && $this->elementRef !== '') { + $props['focus_ref'] = $this->elementRef; + } + if ($this->changeCallback !== null) { $props['on_change'] = $registry->register($this->changeCallback); } diff --git a/tests/BaseTextInputAutofocusTest.php b/tests/BaseTextInputAutofocusTest.php new file mode 100644 index 0000000..121c088 --- /dev/null +++ b/tests/BaseTextInputAutofocusTest.php @@ -0,0 +1,36 @@ +applyAttributes(['autofocus' => true]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['autofocus'])->toBeTrue(); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('is absent when not requested', function () { + $input = new BareTextInput; + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('autofocus'); +}); + +it('is absent when the bound expression is false', function () { + $input = new BareTextInput; + $input->applyAttributes(['autofocus' => false]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('autofocus'); +}); diff --git a/tests/BaseTextInputNextFocusTest.php b/tests/BaseTextInputNextFocusTest.php new file mode 100644 index 0000000..ab030f0 --- /dev/null +++ b/tests/BaseTextInputNextFocusTest.php @@ -0,0 +1,65 @@ +applyAttributes(['next-focus' => 'email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('email'); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('accepts the camelCase attribute spelling', function () { + $input = new FilledTextInput; + $input->applyAttributes(['nextFocus' => 'pin']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('pin'); +}); + +it('treats an empty ref as unset', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['next-focus' => ' ']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('next_focus'); +}); + +it('trims the target ref', function () { + $input = new OutlinedTextInput; + $input->nextFocus(' email '); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('email'); +}); + +it('surfaces the element ref as the focus_ref prop', function () { + $input = new OutlinedTextInput; + $input->ref('email'); + $input->applyAttributes(['placeholder' => 'Email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['focus_ref'])->toBe('email'); +}); + +it('does not serialize focus_ref without a ref', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['placeholder' => 'Email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('focus_ref'); +}); diff --git a/tests/BaseTextInputSubmitLabelTest.php b/tests/BaseTextInputSubmitLabelTest.php new file mode 100644 index 0000000..5253553 --- /dev/null +++ b/tests/BaseTextInputSubmitLabelTest.php @@ -0,0 +1,59 @@ +applyAttributes(['submit-label' => 'next']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('next'); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('accepts every documented label value', function (string $label) { + $input = new OutlinedTextInput; + $input->applyAttributes(['submit-label' => $label]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe($label); +})->with(['next', 'done', 'go', 'search', 'send', 'return']); + +it('accepts the camelCase attribute spelling', function () { + $input = new FilledTextInput; + $input->applyAttributes(['submitLabel' => 'send']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('send'); +}); + +it('normalizes case and surrounding whitespace', function () { + $input = new OutlinedTextInput; + $input->submitLabel(' Next '); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('next'); +}); + +it('does not serialize the prop when unset', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['placeholder' => 'Name']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('submit_label'); +}); + +it('rejects an unknown submit label', function () { + (new OutlinedTextInput)->submitLabel('confirm'); +})->throws(InvalidArgumentException::class, 'Unknown submit-label `confirm`');