Skip to content

Commit c34332c

Browse files
oskarbrueningclaude
andcommitted
fix: keep focus and caret when typing in ody-input text fields
Typing a character into <ody-input>, <ody-inline-input>, or <ody-search-input> reflected the value back through the observed `value` attribute, so the base class ran a destructive re-render on every keystroke — recreating the native <input> and dropping focus and caret position. Override attributeChangedCallback in the three controlled inputs to special-case `value`: push it into the live control in place via a new reflectControlValue helper (a no-op when the control already holds it, so the caret is never disturbed) and update the character counter / clear button imperatively, skipping render(). Every other observed attribute still re-renders chrome through the base. ody-money-input / ody-percentage-input were already unaffected — they write the private #value on input and only reflect on blur. Adds regression tests (focus + caret retained across keystrokes, clear button toggled in place, chrome still re-renders) and documents the invariant in ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 79d8b8f commit c34332c

6 files changed

Lines changed: 191 additions & 17 deletions

File tree

docs/internal/ARCHITECTURE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,17 @@ Load-bearing rules:
245245
across re-renders through a `[data-ody-slot]` placeholder. The first render is
246246
deferred one microtask (so parser/`innerHTML` children are attached before the
247247
slot is captured); attribute-change re-renders are synchronous.
248+
- **Text fields update `value` in place, never re-render.** The native
249+
`<input>`/`<textarea>` already reflects what the user typed, so a destructive
250+
re-render on each keystroke would drop focus and caret. The controlled inputs
251+
(`ody-input`, `ody-inline-input`, `ody-search-input`) therefore `override
252+
attributeChangedCallback` to special-case `value`: they push it into the live
253+
control via `reflectControlValue` (a no-op when the control already holds it)
254+
and toggle the counter/clear-button imperatively, skipping `render()`. Every
255+
other observed attribute changes chrome and still re-renders through the base.
256+
(`ody-money-input`/`ody-percentage-input` sidestep the issue differently —
257+
they write the private `#value` on input and only reflect the attribute on
258+
blur, when focus has already left.)
248259
- **Localization (`i18n.ts`).** Components' built-in strings (aria-labels,
249260
default placeholders, check-in-status labels) go through `OdyElement.term(key)`
250261
/ `localized(attr, key)`, never hardcoded. The active language is resolved from

src/ui/base.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ export function classes(...parts: Array<string | false | null | undefined>): str
168168
return parts.filter(Boolean).join(' ');
169169
}
170170

171+
/**
172+
* Push a controlled `value` into a native input/textarea in place. Skips the
173+
* write when the control already holds it (user typing has updated it) so the
174+
* caret and selection are never disturbed. Used by the text-field components to
175+
* reflect `value` changes without a destructive re-render.
176+
*/
177+
export function reflectControlValue(
178+
control: HTMLInputElement | HTMLTextAreaElement | null,
179+
value: string,
180+
): void {
181+
if (control && control.value !== value) control.value = value;
182+
}
183+
171184
/**
172185
* Register a custom element under `tag`, guarding against double registration
173186
* (and against running in a non-DOM environment such as a Node import).

src/ui/components/inline-input.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OdyElement, classes, define } from '../base.js';
1+
import { OdyElement, classes, define, reflectControlValue } from '../base.js';
22
import { iconSvg } from '../icons.js';
33

44
export type OdyInlineInputSize = 'base' | 'small';
@@ -33,6 +33,29 @@ export class OdyInlineInput extends OdyElement {
3333
this.setAttribute('value', next);
3434
}
3535

36+
/**
37+
* Reflect `value` into the live control in place — the native field already
38+
* shows what the user typed, so rebuilding it (as a full re-render would)
39+
* needlessly drops focus and caret. Every other observed attribute changes
40+
* the chrome and still re-renders via the base implementation.
41+
*/
42+
override attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void {
43+
if (name === 'value') {
44+
if (oldValue === newValue) return;
45+
const value = newValue ?? '';
46+
reflectControlValue(
47+
this.querySelector<HTMLInputElement | HTMLTextAreaElement>(
48+
'.ody-inline-input__field, .ody-inline-input__textarea',
49+
),
50+
value,
51+
);
52+
this.#syncCounter(value);
53+
this.#syncClearButton(value);
54+
return;
55+
}
56+
super.attributeChangedCallback();
57+
}
58+
3659
protected render(): void {
3760
const size = this.attr('size', 'base');
3861
const isTextarea = this.flag('textarea');
@@ -80,12 +103,7 @@ export class OdyInlineInput extends OdyElement {
80103
` placeholder="${this.esc(this.attr('placeholder'))}"${maxlengthAttr}` +
81104
`${isReadonly ? ' readonly' : ''}${isDisabled ? ' disabled' : ''} />`;
82105

83-
const clearEnabled =
84-
!this.flag('no-clear') && !isDisabled && !isReadonly && value !== '';
85-
const clearEl = clearEnabled
86-
? `<button type="button" class="btn ody-inline-input__clear-button" aria-label="${this.localized('clear-label', 'clear')}">` +
87-
`${iconSvg('close', 'icon__svg clear-icon')}</button>`
88-
: '';
106+
const clearEl = this.#clearEnabled(value) ? this.#clearButtonHtml() : '';
89107

90108
const showCounter = !isReadonly && !isDisabled && maxlength !== '';
91109
const footerNeeded = showCounter || caption !== '' || warning !== '' || info !== '';
@@ -124,7 +142,8 @@ export class OdyInlineInput extends OdyElement {
124142
#onInput = (event: Event): void => {
125143
event.stopPropagation();
126144
const value = (event.target as HTMLInputElement).value;
127-
this.#syncCounter(value);
145+
// Reflecting to the `value` attribute drives the counter and clear button
146+
// in place (see attributeChangedCallback) without a focus-dropping rebuild.
128147
this.value = value;
129148
this.dispatchEvent(new CustomEvent('input', { detail: { value }, bubbles: true }));
130149
};
@@ -146,6 +165,30 @@ export class OdyInlineInput extends OdyElement {
146165
const max = this.attr('maxlength');
147166
if (counter && max) counter.textContent = `${value.length} / ${max}`;
148167
}
168+
169+
/** Whether the clear button should be shown for the given value. */
170+
#clearEnabled(value: string): boolean {
171+
return !this.flag('no-clear') && !this.flag('disabled') && !this.flag('readonly') && value !== '';
172+
}
173+
174+
/** Markup for the clear button (shared by render and the in-place sync). */
175+
#clearButtonHtml(): string {
176+
return `<button type="button" class="btn ody-inline-input__clear-button" aria-label="${this.localized('clear-label', 'clear')}">` +
177+
`${iconSvg('close', 'icon__svg clear-icon')}</button>`;
178+
}
179+
180+
/** Add or remove the clear button in place as the value gains/loses content. */
181+
#syncClearButton(value: string): void {
182+
const existing = this.querySelector('.ody-inline-input__clear-button');
183+
if (this.#clearEnabled(value)) {
184+
if (existing) return;
185+
const control = this.querySelector('.ody-inline-input__field, .ody-inline-input__textarea');
186+
control?.insertAdjacentHTML('afterend', this.#clearButtonHtml());
187+
this.querySelector('.ody-inline-input__clear-button')?.addEventListener('click', this.#onClear);
188+
} else {
189+
existing?.remove();
190+
}
191+
}
149192
}
150193

151194
define('ody-inline-input', OdyInlineInput);

src/ui/components/input.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OdyElement, classes, define } from '../base.js';
1+
import { OdyElement, classes, define, reflectControlValue } from '../base.js';
22
import { iconSvg } from '../icons.js';
33

44
export type OdyInputSize = 'base' | 'small';
@@ -44,6 +44,29 @@ export class OdyInput extends OdyElement {
4444
this.setAttribute('value', next);
4545
}
4646

47+
/**
48+
* Reflect `value` into the live control in place — the native field already
49+
* shows what the user typed, so rebuilding it (as a full re-render would)
50+
* needlessly drops focus and caret. Every other observed attribute changes
51+
* the chrome and still re-renders via the base implementation.
52+
*/
53+
override attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void {
54+
if (name === 'value') {
55+
if (oldValue === newValue) return;
56+
const value = newValue ?? '';
57+
reflectControlValue(
58+
this.querySelector<HTMLInputElement | HTMLTextAreaElement>(
59+
'.ody-input__field, .ody-input__textarea',
60+
),
61+
value,
62+
);
63+
this.#syncCounter(value);
64+
this.#syncClearButton(value);
65+
return;
66+
}
67+
super.attributeChangedCallback();
68+
}
69+
4770
protected render(): void {
4871
const size = this.attr('size', 'base');
4972
const isTextarea = this.flag('textarea');
@@ -90,12 +113,7 @@ export class OdyInput extends OdyElement {
90113
` placeholder="${this.esc(this.attr('placeholder'))}"${maxlengthAttr}` +
91114
`${isReadonly ? ' readonly' : ''}${isDisabled ? ' disabled' : ''} />`;
92115

93-
const clearEnabled =
94-
!this.flag('no-clear') && !isDisabled && !isReadonly && value !== '';
95-
const clearEl = clearEnabled
96-
? `<button type="button" class="btn ody-input__clear-button" aria-label="${this.localized('clear-label', 'clear')}">` +
97-
`${iconSvg('close', 'icon__svg clear-icon')}</button>`
98-
: '';
116+
const clearEl = this.#clearEnabled(value) ? this.#clearButtonHtml() : '';
99117

100118
const showCounter = !isReadonly && !isDisabled && maxlength !== '';
101119
const messages = classes(
@@ -139,7 +157,8 @@ export class OdyInput extends OdyElement {
139157
#onInput = (event: Event): void => {
140158
event.stopPropagation();
141159
const value = (event.target as HTMLInputElement).value;
142-
this.#syncCounter(value);
160+
// Reflecting to the `value` attribute drives the counter and clear button
161+
// in place (see attributeChangedCallback) without a focus-dropping rebuild.
143162
this.value = value;
144163
this.dispatchEvent(new CustomEvent('input', { detail: { value }, bubbles: true }));
145164
};
@@ -162,6 +181,30 @@ export class OdyInput extends OdyElement {
162181
const max = this.attr('maxlength');
163182
if (counter && max) counter.textContent = `${value.length} / ${max}`;
164183
}
184+
185+
/** Whether the clear button should be shown for the given value. */
186+
#clearEnabled(value: string): boolean {
187+
return !this.flag('no-clear') && !this.flag('disabled') && !this.flag('readonly') && value !== '';
188+
}
189+
190+
/** Markup for the clear button (shared by render and the in-place sync). */
191+
#clearButtonHtml(): string {
192+
return `<button type="button" class="btn ody-input__clear-button" aria-label="${this.localized('clear-label', 'clear')}">` +
193+
`${iconSvg('close', 'icon__svg clear-icon')}</button>`;
194+
}
195+
196+
/** Add or remove the clear button in place as the value gains/loses content. */
197+
#syncClearButton(value: string): void {
198+
const existing = this.querySelector('.ody-input__clear-button');
199+
if (this.#clearEnabled(value)) {
200+
if (existing) return;
201+
const control = this.querySelector('.ody-input__field, .ody-input__textarea');
202+
control?.insertAdjacentHTML('afterend', this.#clearButtonHtml());
203+
this.querySelector('.ody-input__clear-button')?.addEventListener('click', this.#onClear);
204+
} else {
205+
existing?.remove();
206+
}
207+
}
165208
}
166209

167210
define('ody-input', OdyInput);

src/ui/components/search-input.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OdyElement, classes, define } from '../base.js';
1+
import { OdyElement, classes, define, reflectControlValue } from '../base.js';
22
import { iconSvg } from '../icons.js';
33

44
export type OdySearchInputSize = 'base' | 'small';
@@ -37,6 +37,21 @@ export class OdySearchInput extends OdyElement {
3737
this.setAttribute('value', next);
3838
}
3939

40+
/**
41+
* Reflect `value` into the live control in place — the native field already
42+
* shows what the user typed, so rebuilding it (as a full re-render would)
43+
* needlessly drops focus, caret and the `--focused` state. Every other
44+
* observed attribute changes the chrome and still re-renders via the base.
45+
*/
46+
override attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void {
47+
if (name === 'value') {
48+
if (oldValue === newValue) return;
49+
reflectControlValue(this.querySelector<HTMLInputElement>('.ody-input__field'), newValue ?? '');
50+
return;
51+
}
52+
super.attributeChangedCallback();
53+
}
54+
4055
protected render(): void {
4156
const size = this.attr('size', 'base');
4257
const isDisabled = this.flag('disabled');

test/ui/batch3a.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,55 @@ describe('ody-input', () => {
110110
fire(el.querySelector<HTMLInputElement>('.ody-input__field')!, 'input', 'abc');
111111
expect(el.querySelector('.ody-input__length-message')!.textContent).toContain('3 / 5');
112112
});
113+
114+
it('keeps focus and caret while typing (no destructive re-render on value)', async () => {
115+
const el = await mount('<ody-input label="Name"></ody-input>');
116+
const input = el.querySelector<HTMLInputElement>('.ody-input__field')!;
117+
input.focus();
118+
for (const text of ['a', 'ab', 'abc']) {
119+
input.value = text;
120+
input.setSelectionRange(text.length, text.length);
121+
input.dispatchEvent(new Event('input', { bubbles: true }));
122+
}
123+
const current = el.querySelector<HTMLInputElement>('.ody-input__field')!;
124+
expect(current).toBe(input); // same node — never rebuilt
125+
expect(document.activeElement).toBe(input); // focus retained
126+
expect(input.selectionStart).toBe(3); // caret preserved
127+
expect((el as HTMLElement & { value: string }).value).toBe('abc');
128+
});
129+
130+
it('adds and removes the clear button in place as the value changes', async () => {
131+
const el = await mount('<ody-input></ody-input>');
132+
const input = el.querySelector<HTMLInputElement>('.ody-input__field')!;
133+
expect(el.querySelector('.ody-input__clear-button')).toBeNull();
134+
fire(input, 'input', 'x');
135+
const clear = el.querySelector<HTMLButtonElement>('.ody-input__clear-button')!;
136+
expect(clear).not.toBeNull();
137+
expect(el.querySelector('.ody-input__field')).toBe(input); // still not rebuilt
138+
clear.click(); // wired even though added after the initial render
139+
expect((el as HTMLElement & { value: string }).value).toBe('');
140+
expect(el.querySelector('.ody-input__clear-button')).toBeNull();
141+
});
142+
143+
it('reflects a programmatic value into the live field without a rebuild', async () => {
144+
const el = await mount<HTMLElement & { value: string }>('<ody-input></ody-input>');
145+
const input = el.querySelector<HTMLInputElement>('.ody-input__field')!;
146+
el.value = 'set externally';
147+
expect(el.querySelector('.ody-input__field')).toBe(input);
148+
expect(input.value).toBe('set externally');
149+
});
150+
151+
it('still re-renders chrome for non-value attributes, and no-ops on an unchanged value', async () => {
152+
const el = await mount('<ody-input value="hi"></ody-input>');
153+
const input = el.querySelector<HTMLInputElement>('.ody-input__field')!;
154+
// Re-setting the same value is a no-op — the field is not rebuilt.
155+
el.setAttribute('value', 'hi');
156+
expect(el.querySelector('.ody-input__field')).toBe(input);
157+
// A chrome attribute (label) still re-renders via the base implementation.
158+
el.setAttribute('label', 'Name');
159+
expect(el.querySelector('.ody-input__label')!.textContent).toBe('Name');
160+
expect(el.querySelector('.ody-input__field')).not.toBe(input); // rebuilt
161+
});
113162
});
114163

115164
describe('ody-inline-input', () => {

0 commit comments

Comments
 (0)