Skip to content
Draft
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
4 changes: 3 additions & 1 deletion resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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")),
Expand Down
24 changes: 23 additions & 1 deletion resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -64,6 +66,7 @@ struct NativeUITextInputCore: View {
}
}
.font(.system(size: textSize))
.modifier(AutoGrowingInputModifier(enabled: multiline && autoGrow))
.tint(tintColor)
.keyboardType(keyboard)
.disabled(disabled || readOnly)
Expand Down Expand Up @@ -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<Int> {
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 {
Expand Down
79 changes: 7 additions & 72 deletions src/Elements/BaseTextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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)
Expand All @@ -30,6 +31,8 @@
*/
abstract class BaseTextInput extends Element
{
use ConfiguresTextInputBehavior;

/** @var array<string, mixed> */
protected array $inputProps = [];

Expand Down Expand Up @@ -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']));
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions src/Elements/Concerns/ConfiguresTextInputBehavior.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace Nativephp\NativeUi\Elements\Concerns;

trait ConfiguresTextInputBehavior
{
/** 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 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;
}
}
53 changes: 53 additions & 0 deletions tests/TextInputAutoGrowTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

beforeEach(function () {
$this->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');
});