Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/forty-showers-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@sl-design-system/search-field': patch
---

Debounce the `sl-search` event while typing

Previously applications using the search field component would have to debounce the `sl-search` event themselves. With this change the component now debounces the event internally with a default delay of 300ms.
117 changes: 117 additions & 0 deletions packages/components/search-field/src/search-field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,121 @@ describe('sl-search-field', () => {
expect(onSearch).to.be.calledWith('Foo');
});
});

describe('debounced search', () => {
beforeEach(async () => {
el = await fixture(html`<sl-search-field></sl-search-field>`);
});

it('should emit sl-search event 300ms after typing stops', async () => {
const onSearch: (value: string) => void = spy();

el.addEventListener('sl-search', (event: SlSearchEvent) => onSearch(event.detail));
el.focus();

// Type a character
await userEvent.type(el.input, 'a');

// Should not emit immediately
expect(onSearch).not.to.have.been.called;

// Wait for debounce (300ms)
await new Promise(resolve => setTimeout(resolve, 350));

// Should have emitted after debounce period
expect(onSearch).to.have.been.calledOnce;
expect(onSearch).to.have.been.calledWith('a');
});

it('should reset debounce timer when typing continues', async () => {
const onSearch: (value: string) => void = spy();

el.addEventListener('sl-search', (event: SlSearchEvent) => onSearch(event.detail));
el.focus();

// Type first character
await userEvent.type(el.input, 'h');

// Wait 200ms (less than debounce period)
await new Promise(resolve => setTimeout(resolve, 200));

// Type second character - should reset timer
await userEvent.type(el.input, 'e');

// Wait another 200ms (still less than 300ms from second character)
await new Promise(resolve => setTimeout(resolve, 200));

// Should not have emitted yet
expect(onSearch).not.to.have.been.called;

// Wait for remaining debounce time
await new Promise(resolve => setTimeout(resolve, 150));

// Should have emitted with full text
expect(onSearch).to.have.been.calledOnce;
expect(onSearch).to.have.been.calledWith('he');
});

it('should emit multiple events for separate typing sessions', async () => {
const onSearch: (value: string) => void = spy();

el.addEventListener('sl-search', (event: SlSearchEvent) => onSearch(event.detail));
el.focus();

// First typing session
await userEvent.type(el.input, 'hello');
await new Promise(resolve => setTimeout(resolve, 350));

expect(onSearch).to.have.been.calledOnce;
expect(onSearch).to.have.been.calledWith('hello');

// Clear and start new typing session
el.clear();
await userEvent.type(el.input, 'world');
await new Promise(resolve => setTimeout(resolve, 350));

expect(onSearch).to.have.been.calledTwice;
expect(onSearch).to.have.been.calledWith('world');
});

it('should not emit search event for empty value after debounce', async () => {
const onSearch: (value: string) => void = spy();

el.addEventListener('sl-search', (event: SlSearchEvent) => onSearch(event.detail));
el.focus();

// Type and then delete
await userEvent.type(el.input, 'a');
await userEvent.keyboard('{Backspace}');

// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 350));

// Should not emit for empty value
expect(onSearch).not.to.have.been.called;
});

it('should cancel debounced search when Enter is pressed', async () => {
const onSearch: (value: string) => void = spy();

el.addEventListener('sl-search', (event: SlSearchEvent) => onSearch(event.detail));
el.focus();

// Type some text
await userEvent.type(el.input, 'test');

// Press Enter before debounce completes
await userEvent.keyboard('{Enter}');

// Should emit immediately from Enter press
expect(onSearch).to.have.been.calledOnce;
expect(onSearch).to.have.been.calledWith('test');

// Wait for where debounce would have fired
await new Promise(resolve => setTimeout(resolve, 350));

// Should still only have been called once (debounce was cancelled)
expect(onSearch).to.have.been.calledOnce;
});
});
});
38 changes: 37 additions & 1 deletion packages/components/search-field/src/search-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ export class SearchField extends TextField {
/** @internal */
static override styles: CSSResultGroup = [TextField.styles, styles];

/** @internal Debounce timer for search events */
#debounceTimer?: ReturnType<typeof setTimeout>;

// eslint-disable-next-line no-unused-private-class-members
#events = new EventsController(this, { keydown: this.#onKeyDown });
#events = new EventsController(this, { keydown: this.#onKeyDown, input: this.#onInput });

/** @internal Emits when the user clears the field. */
@event({ name: 'sl-clear' }) clearEvent!: EventEmitter<SlClearEvent>;
Expand All @@ -48,6 +51,12 @@ export class SearchField extends TextField {
this.prepend(style);
}

override disconnectedCallback(): void {
this.#clearDebounceTimer();

super.disconnectedCallback();
}

override renderPrefix(): TemplateResult {
return html`
<slot name="prefix">
Expand All @@ -74,6 +83,7 @@ export class SearchField extends TextField {
/** Clears the value in the input element. */
clear(): void {
this.value = '';
this.#clearDebounceTimer();
this.clearEvent.emit();
}

Expand All @@ -82,6 +92,10 @@ export class SearchField extends TextField {
this.input.focus();
}

#onInput(): void {
this.#startDebounceTimer();
}

#onKeyDown(event: KeyboardEvent): void {
if (this.disabled) {
return;
Expand All @@ -90,11 +104,33 @@ export class SearchField extends TextField {
if (event.key === 'Enter') {
event.preventDefault();

// Cancel debounced search and emit immediately
this.#clearDebounceTimer();
this.searchEvent.emit(this.value?.toString() ?? '');
} else if (event.key === 'Escape') {
event.preventDefault();

this.clear();
}
}

#startDebounceTimer(): void {
this.#clearDebounceTimer();

this.#debounceTimer = setTimeout(() => {
const value = this.value?.toString() ?? '';

// Only emit search event if value is not empty
if (value.trim() !== '') {
this.searchEvent.emit(value);
}
}, 300);
}

#clearDebounceTimer(): void {
if (this.#debounceTimer !== undefined) {
clearTimeout(this.#debounceTimer);
this.#debounceTimer = undefined;
}
}
}