Skip to content

Commit 63144c5

Browse files
committed
feat: more clear input autofocus in summary-item layouts
1 parent 47a6022 commit 63144c5

16 files changed

Lines changed: 690 additions & 0 deletions

src/elements/internal/InternalEditableControl/InternalEditableControl.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getTestData } from '../../../testgen/getTestData';
77
import { InternalEditableControl } from './index';
88
import { InternalControl } from '../InternalControl/InternalControl';
99
import { AddressForm } from '../../public/AddressForm/AddressForm';
10+
import { stub, match } from 'sinon';
1011

1112
import set from 'lodash-es/set';
1213

@@ -350,4 +351,41 @@ describe('InternalEditableControl', () => {
350351
timeout: 5000,
351352
});
352353
});
354+
355+
it('registers click event listener on connect', async () => {
356+
const control = new InternalEditableControl();
357+
const addEventListenerStub = stub(control, 'addEventListener');
358+
359+
control.connectedCallback?.();
360+
361+
expect(addEventListenerStub).to.have.been.calledWith('click', match.func);
362+
363+
addEventListenerStub.restore();
364+
});
365+
366+
it('removes click event listener on disconnect', async () => {
367+
const control = new InternalEditableControl();
368+
const removeEventListenerStub = stub(control, 'removeEventListener');
369+
370+
control.disconnectedCallback?.();
371+
372+
expect(removeEventListenerStub).to.have.been.calledWith('click', match.func);
373+
374+
removeEventListenerStub.restore();
375+
});
376+
377+
it('calls _handleHostClick when click event is fired', async () => {
378+
const control = await fixture<InternalEditableControl>(html`
379+
<foxy-internal-editable-control></foxy-internal-editable-control>
380+
`);
381+
382+
const handleHostClickStub = stub(control, '_handleHostClick' as any);
383+
384+
const mockEvent = new MouseEvent('click', { bubbles: true });
385+
control.dispatchEvent(mockEvent);
386+
387+
expect(handleHostClickStub).to.have.been.calledOnce;
388+
389+
handleHostClickStub.restore();
390+
});
353391
});

src/elements/internal/InternalEditableControl/InternalEditableControl.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export class InternalEditableControl extends InternalControl {
9090
this.__asyncError = validOrError === true ? null : validOrError ?? null;
9191
}, 300);
9292

93+
private __handleHostClick = (evt: MouseEvent) => {
94+
return this._handleHostClick(evt);
95+
};
96+
9397
private __previousValue: unknown | null = null;
9498

9599
private __placeholder: string | null = null;
@@ -239,6 +243,20 @@ export class InternalEditableControl extends InternalControl {
239243
} while (walker.nextNode());
240244
}
241245

246+
connectedCallback(): void {
247+
super.connectedCallback();
248+
this.addEventListener('click', this.__handleHostClick);
249+
}
250+
251+
disconnectedCallback(): void {
252+
super.disconnectedCallback();
253+
this.removeEventListener('click', this.__handleHostClick);
254+
}
255+
256+
protected _handleHostClick(evt: MouseEvent): void {
257+
// Override in subclasses.
258+
}
259+
242260
/**
243261
* A shortcut to get the inferred value from the NucleonElement instance
244262
* up the DOM tree. If no such value or instance exists, returns `undefined`.

src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,4 +463,98 @@ describe('InternalFrequencyControl', () => {
463463
expect(input).to.have.property('value', '2');
464464
expect(select).to.have.property('value', 'times_a_month');
465465
});
466+
467+
it('focuses input when clicking outside INPUT and LABEL in summary-item layout', async () => {
468+
const layout = html`<test-internal-frequency-control
469+
layout="summary-item"
470+
></test-internal-frequency-control>`;
471+
const control = await fixture<TestControl>(layout);
472+
473+
const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input');
474+
if (!input) return; // Skip if input not found
475+
476+
const focusStub = stub(input, 'focus');
477+
478+
const mockEvent = new MouseEvent('click', { bubbles: true });
479+
Object.defineProperty(mockEvent, 'composedPath', {
480+
value: () => [control],
481+
});
482+
483+
// @ts-expect-error accessing protected member for testing purposes
484+
control._handleHostClick(mockEvent);
485+
486+
expect(focusStub).to.have.been.calledOnce;
487+
488+
focusStub.restore();
489+
});
490+
491+
it('does not focus input when layout is not summary-item', async () => {
492+
const layout = html`<test-internal-frequency-control></test-internal-frequency-control>`;
493+
const control = await fixture<TestControl>(layout);
494+
495+
const input = control.renderRoot.querySelector('input');
496+
const focusStub = input ? stub(input, 'focus') : null;
497+
498+
const mockEvent = new MouseEvent('click', { bubbles: true });
499+
Object.defineProperty(mockEvent, 'composedPath', {
500+
value: () => [control],
501+
});
502+
503+
// @ts-expect-error accessing protected member for testing purposes
504+
control._handleHostClick(mockEvent);
505+
506+
if (focusStub) {
507+
expect(focusStub).to.not.have.been.called;
508+
focusStub.restore();
509+
}
510+
});
511+
512+
it('does not focus input when clicking on INPUT element', async () => {
513+
const layout = html`<test-internal-frequency-control
514+
layout="summary-item"
515+
></test-internal-frequency-control>`;
516+
const control = await fixture<TestControl>(layout);
517+
518+
const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input');
519+
if (!input) return;
520+
521+
const focusStub = stub(input, 'focus');
522+
523+
const mockEvent = new MouseEvent('click', { bubbles: true });
524+
Object.defineProperty(mockEvent, 'composedPath', {
525+
value: () => [input],
526+
});
527+
528+
// @ts-expect-error accessing protected member for testing purposes
529+
control._handleHostClick(mockEvent);
530+
531+
expect(focusStub).to.not.have.been.called;
532+
533+
focusStub.restore();
534+
});
535+
536+
it('does not focus input when clicking on LABEL element', async () => {
537+
const layout = html`<test-internal-frequency-control
538+
layout="summary-item"
539+
></test-internal-frequency-control>`;
540+
const control = await fixture<TestControl>(layout);
541+
542+
const input = control.renderRoot.querySelector('vaadin-integer-field')?.querySelector('input');
543+
if (!input) return;
544+
545+
const focusStub = stub(input, 'focus');
546+
const label = document.createElement('label');
547+
548+
const mockEvent = new MouseEvent('click', { bubbles: true });
549+
Object.defineProperty(mockEvent, 'composedPath', {
550+
value: () => [label],
551+
});
552+
553+
// @ts-expect-error accessing protected member for testing purposes
554+
control._handleHostClick(mockEvent);
555+
556+
expect(focusStub).to.not.have.been.called;
557+
558+
focusStub.restore();
559+
});
466560
});

src/elements/internal/InternalFrequencyControl/InternalFrequencyControl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@ export class InternalFrequencyControl extends InternalEditableControl {
154154
if (field && field.value !== this._value) field.value = (this._value ?? '') as string;
155155
}
156156

157+
protected _handleHostClick(evt: MouseEvent): void {
158+
if (this.layout !== 'summary-item') return;
159+
const composedPath = evt.composedPath() as HTMLElement[];
160+
const noOp = new Set(['INPUT', 'LABEL']);
161+
if (!composedPath.some(el => noOp.has(el.tagName))) {
162+
this.renderRoot.querySelector('input')?.focus();
163+
super._handleHostClick(evt);
164+
}
165+
}
166+
157167
private __renderSummaryItemLayout() {
158168
const value = (this._value ?? '') as string;
159169
const [strCount, units] = this.__i18n.parseValue(value);

src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,68 @@ describe('InternalNativeDateControl', () => {
342342
const button = control.renderRoot.querySelector('button');
343343
expect(button).to.have.property('disabled', true);
344344
});
345+
346+
it('focuses input when clicking outside INPUT and LABEL elements', async () => {
347+
const control = await fixture<TestControl>(html`
348+
<test-internal-native-date-control></test-internal-native-date-control>
349+
`);
350+
351+
const input = control.renderRoot.querySelector('input')!;
352+
const focusStub = stub(input, 'focus');
353+
354+
const mockEvent = new MouseEvent('click', { bubbles: true });
355+
Object.defineProperty(mockEvent, 'composedPath', {
356+
value: () => [control],
357+
});
358+
359+
// @ts-expect-error accessing protected member for testing purposes
360+
control._handleHostClick(mockEvent);
361+
362+
expect(focusStub).to.have.been.calledOnce;
363+
364+
focusStub.restore();
365+
});
366+
367+
it('does not focus input when clicking on INPUT element', async () => {
368+
const control = await fixture<TestControl>(html`
369+
<test-internal-native-date-control></test-internal-native-date-control>
370+
`);
371+
372+
const input = control.renderRoot.querySelector('input')!;
373+
const focusStub = stub(input, 'focus');
374+
375+
const mockEvent = new MouseEvent('click', { bubbles: true });
376+
Object.defineProperty(mockEvent, 'composedPath', {
377+
value: () => [input],
378+
});
379+
380+
// @ts-expect-error accessing protected member for testing purposes
381+
control._handleHostClick(mockEvent);
382+
383+
expect(focusStub).to.not.have.been.called;
384+
385+
focusStub.restore();
386+
});
387+
388+
it('does not focus input when clicking on LABEL element', async () => {
389+
const control = await fixture<TestControl>(html`
390+
<test-internal-native-date-control></test-internal-native-date-control>
391+
`);
392+
393+
const input = control.renderRoot.querySelector('input')!;
394+
const focusStub = stub(input, 'focus');
395+
const label = document.createElement('label');
396+
397+
const mockEvent = new MouseEvent('click', { bubbles: true });
398+
Object.defineProperty(mockEvent, 'composedPath', {
399+
value: () => [label],
400+
});
401+
402+
// @ts-expect-error accessing protected member for testing purposes
403+
control._handleHostClick(mockEvent);
404+
405+
expect(focusStub).to.not.have.been.called;
406+
407+
focusStub.restore();
408+
});
345409
});

src/elements/internal/InternalNativeDateControl/InternalNativeDateControl.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,13 @@ export class InternalNativeDateControl extends InternalEditableControl {
112112
protected set _value(newValue: string) {
113113
super._value = newValue as unknown | undefined;
114114
}
115+
116+
protected _handleHostClick(evt: MouseEvent): void {
117+
const composedPath = evt.composedPath() as HTMLElement[];
118+
const noOp = new Set(['INPUT', 'LABEL']);
119+
if (!composedPath.some(el => noOp.has(el.tagName))) {
120+
this.renderRoot.querySelector('input')?.focus();
121+
super._handleHostClick(evt);
122+
}
123+
}
115124
}

src/elements/internal/InternalNumberControl/InternalNumberControl.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,98 @@ describe('InternalNumberControl', () => {
381381

382382
submitMethod.restore();
383383
});
384+
385+
it('focuses input when clicking outside INPUT and LABEL in summary-item layout', async () => {
386+
const layout = html`<test-internal-number-control
387+
layout="summary-item"
388+
></test-internal-number-control>`;
389+
const control = await fixture<TestControl>(layout);
390+
391+
const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input');
392+
if (!input) return; // Skip if input not found
393+
394+
const focusStub = stub(input, 'focus');
395+
396+
const mockEvent = new MouseEvent('click', { bubbles: true });
397+
Object.defineProperty(mockEvent, 'composedPath', {
398+
value: () => [control],
399+
});
400+
401+
// @ts-expect-error accessing protected member for testing purposes
402+
control._handleHostClick(mockEvent);
403+
404+
expect(focusStub).to.have.been.calledOnce;
405+
406+
focusStub.restore();
407+
});
408+
409+
it('does not focus input when layout is not summary-item', async () => {
410+
const layout = html`<test-internal-number-control></test-internal-number-control>`;
411+
const control = await fixture<TestControl>(layout);
412+
413+
const input = control.renderRoot.querySelector('input');
414+
const focusStub = input ? stub(input, 'focus') : null;
415+
416+
const mockEvent = new MouseEvent('click', { bubbles: true });
417+
Object.defineProperty(mockEvent, 'composedPath', {
418+
value: () => [control],
419+
});
420+
421+
// @ts-expect-error accessing protected member for testing purposes
422+
control._handleHostClick(mockEvent);
423+
424+
if (focusStub) {
425+
expect(focusStub).to.not.have.been.called;
426+
focusStub.restore();
427+
}
428+
});
429+
430+
it('does not focus input when clicking on INPUT element', async () => {
431+
const layout = html`<test-internal-number-control
432+
layout="summary-item"
433+
></test-internal-number-control>`;
434+
const control = await fixture<TestControl>(layout);
435+
436+
const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input');
437+
if (!input) return;
438+
439+
const focusStub = stub(input, 'focus');
440+
441+
const mockEvent = new MouseEvent('click', { bubbles: true });
442+
Object.defineProperty(mockEvent, 'composedPath', {
443+
value: () => [input],
444+
});
445+
446+
// @ts-expect-error accessing protected member for testing purposes
447+
control._handleHostClick(mockEvent);
448+
449+
expect(focusStub).to.not.have.been.called;
450+
451+
focusStub.restore();
452+
});
453+
454+
it('does not focus input when clicking on LABEL element', async () => {
455+
const layout = html`<test-internal-number-control
456+
layout="summary-item"
457+
></test-internal-number-control>`;
458+
const control = await fixture<TestControl>(layout);
459+
460+
const input = control.renderRoot.querySelector('vaadin-number-field')?.querySelector('input');
461+
if (!input) return;
462+
463+
const focusStub = stub(input, 'focus');
464+
const label = document.createElement('label');
465+
466+
const mockEvent = new MouseEvent('click', { bubbles: true });
467+
Object.defineProperty(mockEvent, 'composedPath', {
468+
value: () => [label],
469+
});
470+
471+
// @ts-expect-error accessing protected member for testing purposes
472+
control._handleHostClick(mockEvent);
473+
474+
expect(focusStub).to.not.have.been.called;
475+
476+
focusStub.restore();
477+
});
384478
});

src/elements/internal/InternalNumberControl/InternalNumberControl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ export class InternalNumberControl extends InternalEditableControl {
100100
`;
101101
}
102102

103+
protected _handleHostClick(evt: MouseEvent): void {
104+
if (this.layout !== 'summary-item') return;
105+
const composedPath = evt.composedPath() as HTMLElement[];
106+
const noOp = new Set(['INPUT', 'LABEL']);
107+
if (!composedPath.some(el => noOp.has(el.tagName))) {
108+
this.renderRoot.querySelector('input')?.focus();
109+
super._handleHostClick(evt);
110+
}
111+
}
112+
103113
private __renderSummaryItemLayout() {
104114
return html`
105115
<div class="leading-xs">

0 commit comments

Comments
 (0)