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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash: min_lines > max_lines throws on first composition.

parseTextInputProps derives minLines and maxLines independently and never clamps one against the other. With this hunk now forwarding both to BasicTextField, <native:bare-text-input auto-grow min-lines="8" /> produces min_lines=8 with max_lines falling back to 5. Compose's Modifier.heightInLines does require(minLines <= maxLines), so the screen throws IllegalArgumentException.

The Blade path reaches this directly: applyAttributes runs autoGrow() (setting max_lines = 5) and then applies the min-lines attribute, with no clamp in between. Same for ->multiline()->minLines(8).

iOS's new resolvedLineRange() clamps for exactly this reason — Android should do the same in parseTextInputProps (maxLines = max(maxLines, minLines)), which also closes the same pre-existing exposure in the outlined and filled renderers.

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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the clamp belongs — maxLines is resolved here with no reference to minLines, and the next line resolves minLines with no reference to maxLines. Mirroring iOS's resolvedLineRange() (maxLines = max(maxLines, minLines)) fixes the crash for all three renderers at once.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale base — merging as-is reverts main's multiline width fix.

This branch is off f3b0731; origin/main has since rewritten this file (+336 lines). Main's multiline branch already computes the identical clamp:

let lower = max(minLines, 1)
let upper = maxLines > 0 ? max(maxLines, lower) : max(5, lower)

and adds .frame(maxWidth: .infinity, alignment: .leading), with a comment explaining that a vertical-axis TextField reports ~0 intrinsic width when empty and collapses to the icon. This PR's replacement branch has the clamp but not the frame, so resolving the conflict in favor of the PR re-introduces the collapse bug. Main also added a selectionEnabled parallel branch this hunk knows nothing about.

Please rebase onto main before merging.

.foregroundColor(contentColor)
.focused($isFocused)
} else {
Expand All @@ -64,6 +66,7 @@ struct NativeUITextInputCore: View {
}
}
.font(.system(size: textSize))
.modifier(AutoGrowingInputModifier(enabled: multiline && autoGrow))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auto_grow doesn't appear to have a distinct effect on iOS. AutoGrowingInputModifier only applies fixedSize(horizontal: false, vertical: true), but a TextField(axis: .vertical) with lineLimit(lower...upper) already sizes to its intrinsic height and caps at upper — so this is a no-op relative to plain multiline + min-lines/max-lines. The actual growth comes from the lineLimit change above, which main already has.

It's also attached to the whole Group, so secure + auto_grow applies it to a SecureField that has no line range at all.

Either demonstrate a case where it changes layout, or drop it and treat auto_grow as an Android-only hint.

.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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trait extraction conflicts with methods added on main.

This PR deletes the Behavior and Sync mode regions and moves them into a concern. On main that same region has since grown autocapitalize(), keepFocusOnSubmit(), font(), selectionDebounceMs(), and onSelectionChange(), plus a Pint reformat (this PR still uses the old single-line { ... } brace style, which Pint will now reject). Resolving the conflict by taking this PR's side silently drops five shipped public API methods.

The refactor is also unrelated to the auto-grow feature — it'd be safer as its own PR after a rebase, so the feature diff stays reviewable.


/** @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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autoGrow(false) enables auto-grow.

Every other toggle in this trait is fn(bool $value = true). autoGrow(int $minLines = 1, int $maxLines = 5) breaks that pattern: with no strict_types, ->autoGrow(false) coerces to 0, max(1, 0) gives 1, and the method then unconditionally sets multiline = true and auto_grow = true.

So ->autoGrow($userWantsAutoGrow) turns a single-line input into a multiline auto-growing one precisely when the flag is false — and there's no way to disable auto-grow once it's set. Consider autoGrow(bool $value = true, ?int $minLines = null, ?int $maxLines = null), or a separate autoGrowLines().

{
$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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These unconditional writes make the fluent API order-dependent: ->maxLines(10)->autoGrow() silently resets max_lines back to 5, while ->autoGrow()->maxLines(10) works. Only write min_lines/max_lines when the caller actually passed non-default arguments (see the nullable-args suggestion above), or document the overwrite clearly.


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")')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suite asserts on source text rather than behavior — every assertion is file_get_contents(...)->toContain('...'), including whitespace-sensitive strings like this one ('autoGrow = p.getBool("auto_grow")') and 'val autoGrow: Boolean'.

These pass even if autoGrow() writes the wrong prop keys, if applyAttributes never calls it, or if the Android renderer crashes on minLines > maxLines — and they break on any Pint or ktlint realignment.

A behavioral test would be more useful: build OutlinedTextInput::make()->autoGrow(2, 6), resolve the props, and assert auto_grow === true, min_lines === 2, max_lines === 6; plus a Blade-attribute case covering both auto-grow and autoGrow.

->toContain('!multiline && !autoGrow')
->toContain('p.getBool("multiline") || p.getBool("auto_grow")');

expect($bare)
->toContain('singleLine = props.singleLine')
->toContain('props.multiline || props.autoGrow');
});