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
26 changes: 25 additions & 1 deletion packages/uui-input/lib/uui-input.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ export class UUIInputElement extends UUIFormControlMixin(
_input!: HTMLInputElement;

private _type: InputType = 'text';
private _valueOnFocus: string = '';
private _changeEventFiredSinceFocus: boolean = false;

constructor() {
super();
Expand Down Expand Up @@ -312,10 +314,30 @@ export class UUIInputElement extends UUIFormControlMixin(
protected onChange(e: Event) {
e.stopPropagation();
this.pristine = false;
this._changeEventFiredSinceFocus = true;

this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE));
}

protected onFocus() {
// Store the value when the input receives focus
this._valueOnFocus = String(this.value || '');
this._changeEventFiredSinceFocus = false;
}

protected onBlur() {
// Check if the value has changed since focus
// This ensures change events are fired on Safari even when native change events don't fire
// Only fire if the native change event hasn't already fired to avoid duplicates
if (
!this._changeEventFiredSinceFocus &&
this._valueOnFocus !== this.value
) {
this.pristine = false;
this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE));
}
}

protected renderPrepend() {
return html`<slot name="prepend"></slot>`;
}
Expand Down Expand Up @@ -359,7 +381,9 @@ export class UUIInputElement extends UUIFormControlMixin(
?readonly=${this.readonly}
tabindex=${ifDefined(this.tabIndex)}
@input=${this.onInput}
@change=${this.onChange} />`;
@change=${this.onChange}
@focus=${this.onFocus}
@blur=${this.onBlur} />`;
}

private renderAutoWidthBackground() {
Expand Down
107 changes: 107 additions & 0 deletions packages/uui-input/lib/uui-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,113 @@ describe('UuiInputElement', () => {
expect(innerEvent.type).to.equal(UUIInputEvent.CHANGE);
expect(innerEvent!.target).to.equal(innerElement);
});

it('emits a change event when value changes and input loses focus (Safari fix)', async () => {
// Simulate Safari behavior where native change event doesn't fire
const listener = oneEvent(element, UUIInputEvent.CHANGE);

// Simulate user focusing the input
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);

// Simulate user typing
input.value = 'new value';
input.dispatchEvent(new Event('input'));
await elementUpdated(element);

// Simulate user blurring the input (without native change event)
input.dispatchEvent(new Event('blur'));

const event = await listener;
expect(event).to.exist;
expect(event.type).to.equal(UUIInputEvent.CHANGE);
expect(element.value).to.equal('new value');
});

it('does not emit change event on blur if value has not changed', async () => {
let changeEventCount = 0;
element.addEventListener(UUIInputEvent.CHANGE, () => {
changeEventCount++;
});

// Simulate user focusing the input
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);

// Don't change the value

// Simulate user blurring the input
input.dispatchEvent(new Event('blur'));
await elementUpdated(element);

// Wait a bit to ensure no event is fired
await sleep(50);

expect(changeEventCount).to.equal(0);
});

it('handles multiple focus/blur cycles correctly', async () => {
let changeEventCount = 0;
element.addEventListener(UUIInputEvent.CHANGE, () => {
changeEventCount++;
});

// First cycle - change value
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);
input.value = 'value1';
input.dispatchEvent(new Event('input'));
await elementUpdated(element);
input.dispatchEvent(new Event('blur'));
await elementUpdated(element);

expect(changeEventCount).to.equal(1);

// Second cycle - no change
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);
input.dispatchEvent(new Event('blur'));
await elementUpdated(element);

expect(changeEventCount).to.equal(1);

// Third cycle - change value again
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);
input.value = 'value2';
input.dispatchEvent(new Event('input'));
await elementUpdated(element);
input.dispatchEvent(new Event('blur'));
await elementUpdated(element);

expect(changeEventCount).to.equal(2);
});

it('does not emit duplicate change events when native change fires (non-Safari browsers)', async () => {
let changeEventCount = 0;
element.addEventListener(UUIInputEvent.CHANGE, () => {
changeEventCount++;
});

// Simulate typical browser behavior where native change event fires
input.dispatchEvent(new Event('focus'));
await elementUpdated(element);

input.value = 'new value';
input.dispatchEvent(new Event('input'));
await elementUpdated(element);

// Native change event fires
input.dispatchEvent(new Event('change'));
await elementUpdated(element);

// Blur event fires after change
input.dispatchEvent(new Event('blur'));
await elementUpdated(element);

// Should only fire ONE change event (from native change, not from blur)
expect(changeEventCount).to.equal(1);
});
});
});

Expand Down