diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a118049..ddd27aa 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -96,7 +96,9 @@ object BareTextInputRenderer { else -> theme.primary } ), - singleLine = !props.multiline, + singleLine = props.singleLine, + minLines = if (props.multiline || props.autoGrow) props.minLines else 1, + maxLines = if (props.multiline || props.autoGrow) props.maxLines else 1, decorationBox = { innerTextField -> if (text.isEmpty() && props.placeholder.isNotEmpty()) { Text( diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 422a460..d6097aa 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -45,6 +45,7 @@ internal data class TextInputProps( val trailingIcon: String, val secure: Boolean, val multiline: Boolean, + val autoGrow: Boolean, val maxLines: Int, val minLines: Int, val maxLength: Int, @@ -66,7 +67,7 @@ internal data class TextInputProps( val enabled: Boolean get() = !disabled && !loading val visualTransformation: VisualTransformation get() = if (secure) PasswordVisualTransformation() else VisualTransformation.None - val singleLine: Boolean get() = !multiline + val singleLine: Boolean get() = !multiline && !autoGrow /** Numeric sp size for the chromeless variant. Tracks token fallbacks. */ val textSize: Int get() = when (size) { @@ -103,7 +104,8 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { trailingIcon = p.getString("trailing_icon"), secure = p.getBool("secure"), multiline = p.getBool("multiline"), - maxLines = p.getInt("max_lines").let { if (it > 0) it else if (p.getBool("multiline")) 5 else 1 }, + autoGrow = p.getBool("auto_grow"), + maxLines = p.getInt("max_lines").let { if (it > 0) it else if (p.getBool("multiline") || p.getBool("auto_grow")) 5 else 1 }, minLines = p.getInt("min_lines").let { if (it > 0) it else 1 }, maxLength = p.getInt("max_length"), keyboard = resolveKeyboardType(p.getString("keyboard")), diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 498b905..3d1455a 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -33,6 +33,8 @@ struct NativeUITextInputCore: View { let serverValue = p.getString("value") let secure = p.getBool("secure") let multiline = p.getBool("multiline") + let autoGrow = p.getBool("auto_grow") + let minLines = p.getInt("min_lines") let maxLength = p.getInt("max_length") let maxLines = p.getInt("max_lines") let disabled = p.getBool("disabled") @@ -54,7 +56,7 @@ struct NativeUITextInputCore: View { .focused($isFocused) } else if multiline { TextField(placeholder, text: $text, axis: .vertical) - .lineLimit(maxLines > 0 ? 1...maxLines : 1...5) + .lineLimit(resolvedLineRange(minLines: minLines, maxLines: maxLines)) .foregroundColor(contentColor) .focused($isFocused) } else { @@ -64,6 +66,7 @@ struct NativeUITextInputCore: View { } } .font(.system(size: textSize)) + .modifier(AutoGrowingInputModifier(enabled: multiline && autoGrow)) .tint(tintColor) .keyboardType(keyboard) .disabled(disabled || readOnly) @@ -154,6 +157,25 @@ struct NativeUITextInputCore: View { } } +private struct AutoGrowingInputModifier: ViewModifier { + let enabled: Bool + + func body(content: Content) -> some View { + if enabled { + content.fixedSize(horizontal: false, vertical: true) + } else { + content + } + } +} + +private func resolvedLineRange(minLines: Int, maxLines: Int) -> ClosedRange { + let lowerBound = max(minLines, 1) + let upperBound = max(maxLines > 0 ? maxLines : 5, lowerBound) + + return lowerBound...upperBound +} + /// 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 6ab3ab5..35a91f0 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -7,6 +7,7 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Elements\Concerns\ConfiguresTextInputBehavior; /** * Shared base for the text input variants (`outlined-text-input`, @@ -19,7 +20,7 @@ * Allowed per-instance: * - `value`, `placeholder`, `label`, `supporting` (content) * - `disabled`, `readOnly`, `error`, `loading` (state) - * - `keyboard`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior) + * - `keyboard`, `secure`, `maxLength`, `multiline`, `autoGrow`, `maxLines`, `minLines` (behavior) * - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations) * - `size` (sm | md | lg) * - `a11y-label`, `a11y-hint` (accessibility) @@ -30,6 +31,8 @@ */ abstract class BaseTextInput extends Element { + use ConfiguresTextInputBehavior; + /** @var array */ protected array $inputProps = []; @@ -67,6 +70,9 @@ public function applyAttributes(array $attrs): void $this->maxLength((int) ($attrs['maxLength'] ?? $attrs['max-length'])); } if (! empty($attrs['multiline'])) { $this->multiline(); } + if (! empty($attrs['autoGrow']) || ! empty($attrs['auto-grow'])) { + $this->autoGrow(); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -162,51 +168,6 @@ public function loading(bool $value = true): static return $this; } - // ── Behavior ───────────────────────────────────────────────────────────── - - /** Keyboard hint — "text" (default) | "number" | "email" | "phone" | "url" | "decimal" | "password" */ - public function keyboard(string|int $type): static - { - $this->inputProps['keyboard'] = $type; - - return $this; - } - - public function secure(bool $value = true): static - { - $this->inputProps['secure'] = $value; - - return $this; - } - - public function maxLength(int $length): static - { - $this->inputProps['max_length'] = $length; - - return $this; - } - - public function multiline(bool $value = true): static - { - $this->inputProps['multiline'] = $value; - - return $this; - } - - public function maxLines(int $lines): static - { - $this->inputProps['max_lines'] = $lines; - - return $this; - } - - public function minLines(int $lines): static - { - $this->inputProps['min_lines'] = $lines; - - return $this; - } - // ── Decorations ────────────────────────────────────────────────────────── public function prefix(string $text): static @@ -279,32 +240,6 @@ public function a11yHint(string $value): static return $this; } - // ── Sync mode ──────────────────────────────────────────────────────────── - - /** - * How the native side should dispatch change events back to PHP. - * - * 'live' — every keystroke (default, matches `wire:model.live`) - * 'blur' — only when the field loses focus / user submits - * 'debounce' — after `debounce_ms` of inactivity - * - * Typically set indirectly via `native:model.live` / `.blur` / `.debounce.Xms` - * in Blade — the precompiler translates those into this prop. - */ - public function syncMode(string $mode): static - { - $this->inputProps['sync_mode'] = $mode; - - return $this; - } - - public function debounceMs(int $ms): static - { - $this->inputProps['debounce_ms'] = $ms; - - return $this; - } - // ── Callbacks ──────────────────────────────────────────────────────────── public function onChange(string $method): static diff --git a/src/Elements/Concerns/ConfiguresTextInputBehavior.php b/src/Elements/Concerns/ConfiguresTextInputBehavior.php new file mode 100644 index 0000000..ad3f199 --- /dev/null +++ b/src/Elements/Concerns/ConfiguresTextInputBehavior.php @@ -0,0 +1,86 @@ +inputProps['keyboard'] = $type; + + return $this; + } + + public function secure(bool $value = true): static + { + $this->inputProps['secure'] = $value; + + return $this; + } + + public function maxLength(int $length): static + { + $this->inputProps['max_length'] = $length; + + return $this; + } + + public function multiline(bool $value = true): static + { + $this->inputProps['multiline'] = $value; + + return $this; + } + + public function autoGrow(int $minLines = 1, int $maxLines = 5): static + { + $minLines = max(1, $minLines); + $maxLines = max($minLines, $maxLines); + + $this->inputProps['multiline'] = true; + $this->inputProps['auto_grow'] = true; + $this->inputProps['min_lines'] = $minLines; + $this->inputProps['max_lines'] = $maxLines; + + return $this; + } + + public function maxLines(int $lines): static + { + $this->inputProps['max_lines'] = $lines; + + return $this; + } + + public function minLines(int $lines): static + { + $this->inputProps['min_lines'] = $lines; + + return $this; + } + + /** + * How the native side should dispatch change events back to PHP. + * + * 'live' - every keystroke (default, matches `wire:model.live`) + * 'blur' - only when the field loses focus / user submits + * 'debounce' - after `debounce_ms` of inactivity + * + * Typically set indirectly via `native:model.live` / `.blur` / `.debounce.Xms` + * in Blade; the precompiler translates those into this prop. + */ + public function syncMode(string $mode): static + { + $this->inputProps['sync_mode'] = $mode; + + return $this; + } + + public function debounceMs(int $ms): static + { + $this->inputProps['debounce_ms'] = $ms; + + return $this; + } +} diff --git a/tests/TextInputAutoGrowTest.php b/tests/TextInputAutoGrowTest.php new file mode 100644 index 0000000..93f23ef --- /dev/null +++ b/tests/TextInputAutoGrowTest.php @@ -0,0 +1,53 @@ +pluginPath = dirname(__DIR__); +}); + +it('exposes an auto grow text input API from PHP', function () { + $baseInput = file_get_contents($this->pluginPath.'/src/Elements/BaseTextInput.php'); + $behavior = file_get_contents($this->pluginPath.'/src/Elements/Concerns/ConfiguresTextInputBehavior.php'); + + expect($baseInput) + ->toContain('use ConfiguresTextInputBehavior'); + + expect($behavior) + ->toContain('autoGrow') + ->toContain("'auto_grow'") + ->toContain("\$this->inputProps['multiline'] = true") + ->toContain("\$this->inputProps['min_lines'] = \$minLines") + ->toContain("\$this->inputProps['max_lines'] = \$maxLines"); +}); + +it('accepts blade auto grow attributes', function () { + $content = file_get_contents($this->pluginPath.'/src/Elements/BaseTextInput.php'); + + expect($content) + ->toContain("'autoGrow'") + ->toContain("'auto-grow'"); +}); + +it('supports auto grow in the iOS text input core', function () { + $content = file_get_contents($this->pluginPath.'/resources/ios/NativeUITextInputCore.swift'); + + expect($content) + ->toContain('p.getBool("auto_grow")') + ->toContain('p.getInt("min_lines")') + ->toContain('resolvedLineRange(minLines: minLines, maxLines: maxLines)') + ->toContain('fixedSize(horizontal: false, vertical: true)'); +}); + +it('supports auto grow in Android text input renderers', function () { + $shared = file_get_contents($this->pluginPath.'/resources/android/TextInputShared.kt'); + $bare = file_get_contents($this->pluginPath.'/resources/android/BareTextInputRenderer.kt'); + + expect($shared) + ->toContain('val autoGrow: Boolean') + ->toContain('autoGrow = p.getBool("auto_grow")') + ->toContain('!multiline && !autoGrow') + ->toContain('p.getBool("multiline") || p.getBool("auto_grow")'); + + expect($bare) + ->toContain('singleLine = props.singleLine') + ->toContain('props.multiline || props.autoGrow'); +});