-
-
Notifications
You must be signed in to change notification settings - Fork 22
[codex] Add auto-growing text inputs #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where the clamp belongs — |
||
| minLines = p.getInt("min_lines").let { if (it > 0) it else 1 }, | ||
| maxLength = p.getInt("max_length"), | ||
| keyboard = resolveKeyboardType(p.getString("keyboard")), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale base — merging as-is reverts This branch is off let lower = max(minLines, 1)
let upper = maxLines > 0 ? max(maxLines, lower) : max(5, lower)…and adds Please rebase onto |
||
| .foregroundColor(contentColor) | ||
| .focused($isFocused) | ||
| } else { | ||
|
|
@@ -64,6 +66,7 @@ struct NativeUITextInputCore: View { | |
| } | ||
| } | ||
| .font(.system(size: textSize)) | ||
| .modifier(AutoGrowingInputModifier(enabled: multiline && autoGrow)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's also attached to the whole Either demonstrate a case where it changes layout, or drop it and treat |
||
| .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<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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The trait extraction conflicts with methods added on This PR deletes the 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 = []; | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Every other toggle in this trait is So |
||
| { | ||
| $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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These unconditional writes make the fluent API order-dependent: |
||
|
|
||
| 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; | ||
| } | ||
| } | ||
| 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")') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The suite asserts on source text rather than behavior — every assertion is These pass even if A behavioral test would be more useful: build |
||
| ->toContain('!multiline && !autoGrow') | ||
| ->toContain('p.getBool("multiline") || p.getBool("auto_grow")'); | ||
|
|
||
| expect($bare) | ||
| ->toContain('singleLine = props.singleLine') | ||
| ->toContain('props.multiline || props.autoGrow'); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Crash:
min_lines>max_linesthrows on first composition.parseTextInputPropsderivesminLinesandmaxLinesindependently and never clamps one against the other. With this hunk now forwarding both toBasicTextField,<native:bare-text-input auto-grow min-lines="8" />producesmin_lines=8withmax_linesfalling back to5. Compose'sModifier.heightInLinesdoesrequire(minLines <= maxLines), so the screen throwsIllegalArgumentException.The Blade path reaches this directly:
applyAttributesrunsautoGrow()(settingmax_lines = 5) and then applies themin-linesattribute, with no clamp in between. Same for->multiline()->minLines(8).iOS's new
resolvedLineRange()clamps for exactly this reason — Android should do the same inparseTextInputProps(maxLines = max(maxLines, minLines)), which also closes the same pre-existing exposure in the outlined and filled renderers.