Appearance
SearchInput
Demonstrates r-model on a form input, $emit for custom events, $onMount with a teardown return, the parameterized .debounce(300) modifier, conditional rendering (r-if / r-else), and $refs.
Live demo
Type at least 2 characters. The .debounce(300) modifier means search fires only after you stop typing for 300ms. Hit Enter to fire immediately, Escape to clear.
Source — SearchInput.rozie
rozie
<!--
SearchInput.rozie
Demonstrates:
- r-model on a form input (sugar for :value + @input)
- $emit for custom events to the parent
- $computed deriving from $data
- $onMount with cleanup return value (Rozie supports the React-style
"return a teardown function from $onMount" pattern as an alternative
to writing a separate $onUnmount)
- .debounce(ms) parameterized modifier on a template event
- Conditional rendering with r-if / r-else
-->
<rozie name="SearchInput">
<props>
{
placeholder: { type: String, default: 'Search…' },
minLength: { type: Number, default: 2 },
autofocus: { type: Boolean, default: false },
}
</props>
<data>
{
query: '',
}
</data>
<script>
const isValid = $computed(() => $data.query.length >= $props.minLength)
const onSearch = () => {
if (isValid) $emit('search', $data.query)
}
const clear = () => {
$data.query = ''
$emit('clear')
}
$onMount(() => {
if ($props.autofocus) $refs.inputEl?.focus()
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
return () => {
// e.g., abort an in-flight request initialized in this hook
}
})
</script>
<template>
<div class="search-input">
<!--
Modifier on a template event, same grammar as the <listeners> block:
- .debounce(300) waits 300ms after the last keystroke before firing
- .enter triggers immediately on Enter even if the debounce window hasn't elapsed
-->
<input
ref="inputEl"
type="search"
:placeholder="$props.placeholder"
r-model="$data.query"
@input.debounce(300)="onSearch"
@keydown.enter="onSearch"
@keydown.escape="clear"
/>
<button r-if="$data.query.length > 0" class="clear-btn" @click="clear" aria-label="Clear">
×
</button>
<span r-else class="hint">{{ $props.minLength }}+ chars</span>
</div>
</template>
<style>
.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
</style>
</rozie>Vue output
vue
<template>
<div class="search-input">
<input ref="inputElRef" type="search" :placeholder="props.placeholder" v-model="query" @input="debouncedOnSearch" @keydown.enter="onSearch" @keydown.esc="clear" />
<button v-if="query.length > 0" class="clear-btn" aria-label="Clear" @click="clear">
×
</button><span v-else class="hint">{{ props.minLength }}+ chars</span></div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { debounce } from '@rozie/runtime-vue';
const props = withDefaults(
defineProps<{ placeholder?: string; minLength?: number; autofocus?: boolean }>(),
{ placeholder: 'Search…', minLength: 2, autofocus: false }
);
const emit = defineEmits<{
search: [...args: any[]];
clear: [...args: any[]];
}>();
const query = ref('');
const inputElRef = ref<HTMLInputElement>();
const isValid = computed(() => query.value.length >= props.minLength);
const onSearch = () => {
if (isValid.value) emit('search', query.value);
};
const clear = () => {
query.value = '';
emit('clear');
};
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
if (props.autofocus) inputElRef.value?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
_cleanup_0 = () => {
// e.g., abort an in-flight request initialized in this hook
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
const debouncedOnSearch = debounce(onSearch, 300);
</script>
<style scoped>
.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
</style>React output
tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebouncedCallback } from '@rozie/runtime-react';
import styles from './SearchInput.module.css';
interface SearchInputProps {
placeholder?: string;
minLength?: number;
autofocus?: boolean;
onSearch?: (...args: unknown[]) => void;
onClear?: (...args: unknown[]) => void;
}
export default function SearchInput(_props: SearchInputProps): JSX.Element {
const props: SearchInputProps = {
..._props,
placeholder: _props.placeholder ?? 'Search…',
minLength: _props.minLength ?? 2,
autofocus: _props.autofocus ?? false,
};
const [query, setQuery] = useState('');
const inputEl = useRef<HTMLInputElement | null>(null);
const isValid = useMemo(() => query.length >= props.minLength, [props.minLength, query]);
const { onSearch: _rozieProp_onSearch } = props;
const onSearch = useCallback(() => {
if (isValid) _rozieProp_onSearch && _rozieProp_onSearch(query);
}, [_rozieProp_onSearch, isValid, query]);
const { onClear: _rozieProp_onClear } = props;
const clear = useCallback(() => {
setQuery('');
_rozieProp_onClear && _rozieProp_onClear();
}, [_rozieProp_onClear]);
useEffect(() => {
if (props.autofocus) inputEl.current?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
return () => {
};
}, [props.autofocus]);
const _rozieDebouncedOnSearch = useDebouncedCallback(onSearch, [onSearch], 300);
return (
<>
<div className={styles["search-input"]}>
<input ref={inputEl} type="search" placeholder={props.placeholder} value={query} onChange={e => setQuery(e.target.value)} onInput={_rozieDebouncedOnSearch} onKeyDown={(e) => { ((e) => { if (e.key !== 'Enter') return; onSearch(e); })(e); ((e) => { if (e.key !== 'Escape') return; clear(e); })(e); }} />
{(query.length > 0) ? <button className={styles["clear-btn"]} aria-label="Clear" onClick={clear}>
×
</button> : <span className={styles.hint}>{props.minLength}+ chars</span>}</div>
</>
);
}Svelte output
svelte
<script lang="ts">
interface Props {
placeholder?: string;
minLength?: number;
autofocus?: boolean;
onsearch?: (...args: unknown[]) => void;
onclear?: (...args: unknown[]) => void;
}
let {
placeholder = 'Search…',
minLength = 2,
autofocus = false,
onsearch,
onclear,
}: Props = $props();
let query = $state('');
let inputEl = $state<HTMLInputElement | undefined>(undefined);
const onSearch = () => {
if (isValid) onsearch?.(query);
};
const clear = () => {
query = '';
onclear?.();
};
const isValid = $derived(query.length >= minLength);
$effect(() => {
if (autofocus) inputEl?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
return () => {
// e.g., abort an in-flight request initialized in this hook
};
});
const debouncedOnSearch = (() => {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: any[]) => {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => (onSearch)(...args), 300);
};
})();
</script>
<div class="search-input">
<input bind:this={inputEl} type="search" placeholder={placeholder} bind:value={query} oninput={debouncedOnSearch} onkeydown={(e) => { (() => { if (e.key !== 'Enter') return; onSearch(e); })(); (() => { if (e.key !== 'Escape') return; clear(e); })(); }} />
{#if query.length > 0}<button class="clear-btn" aria-label="Clear" onclick={clear}>
×
</button>{:else}<span class="hint">{minLength}+ chars</span>{/if}</div>
<style>
.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
</style>Angular output
ts
import { Component, DestroyRef, ElementRef, ViewEncapsulation, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'rozie-search-input',
standalone: true,
imports: [FormsModule],
template: `
<div class="search-input">
<input #inputEl type="search" [placeholder]="placeholder()" [ngModel]="query()" (ngModelChange)="query.set($event)" [ngModelOptions]="{standalone: true}" (input)="debouncedOnSearch($event)" (keydown)="_merged_keydown_1($event)" />
@if (query().length > 0) {
<button class="clear-btn" aria-label="Clear" (click)="_clear($event)">
×
</button>
} @else {
<span class="hint">{{ minLength() }}+ chars</span>
}</div>
`,
styles: [`
.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
`],
})
export class SearchInput {
placeholder = input<string>('Search…');
minLength = input<number>(2);
autofocus = input<boolean>(false);
query = signal('');
inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
search = output<unknown>();
clear = output<void>();
constructor() {
if (this.autofocus()) this.inputEl()?.nativeElement?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
inject(DestroyRef).onDestroy(() => {
// e.g., abort an in-flight request initialized in this hook
});
}
isValid = computed(() => this.query().length >= this.minLength());
onSearch = () => {
if (this.isValid()) this.search.emit(this.query());
};
_clear = () => {
this.query.set('');
this.clear.emit();
};
private debouncedOnSearch = (() => {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: any[]) => {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => (this.onSearch as (...a: any[]) => any)(...args), 300);
};
})();
private _guardedOnSearch_2 = (e: any) => {
if (e.key !== 'Enter') return;
this.onSearch();
};
private _guarded_clear_3 = (e: any) => {
if (e.key !== 'Escape') return;
this._clear();
};
private _merged_keydown_1 = (e: any) => {
this._guardedOnSearch_2(e);
this._guarded_clear_3(e);
};
}
export default SearchInput;Solid output
tsx
import type { JSX } from 'solid-js';
import { Show, createMemo, createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { createDebouncedHandler } from '@rozie/runtime-solid';
interface SearchInputProps {
placeholder?: string;
minLength?: number;
autofocus?: boolean;
onSearch?: (...args: unknown[]) => void;
onClear?: (...args: unknown[]) => void;
}
export default function SearchInput(_props: SearchInputProps): JSX.Element {
const _merged = mergeProps({ placeholder: 'Search…', minLength: 2, autofocus: false }, _props);
const [local, rest] = splitProps(_merged, ['placeholder', 'minLength', 'autofocus']);
const [query, setQuery] = createSignal('');
const isValid = createMemo(() => query().length >= local.minLength);
onMount(() => {
const _cleanup = (() => {
if (local.autofocus) inputElRef?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
// e.g., abort an in-flight request initialized in this hook
});
});
let inputElRef: HTMLElement | null = null;
const onSearch = () => {
if (isValid()) _props.onSearch?.(query());
};
const clear = () => {
setQuery('');
_props.onClear?.();
};
const _rozieDebouncedOnSearch = createDebouncedHandler(onSearch, 300);
return (
<>
<style>{`.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }`}</style>
<>
<div class={"search-input"}>
<input type="search" ref={(el) => { inputElRef = el as HTMLElement; }} placeholder={local.placeholder} value={query()} onInput={(e) => { (e => setQuery(e.currentTarget.value))(e); _rozieDebouncedOnSearch(e); }} onKeyDown={(e) => { ((e) => { if (e.key !== 'Enter') return; onSearch(); })(e); ((e) => { if (e.key !== 'Escape') return; clear(); })(e); }} />
{<Show when={query().length > 0} fallback={<span class={"hint"}>{local.minLength}+ chars</span>}><button aria-label="Clear" class={"clear-btn"} onClick={clear}>
×
</button></Show>}</div>
</>
</>
);
}Lit output
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { debounce } from '@rozie/runtime-lit';
@customElement('rozie-search-input')
export default class SearchInput extends SignalWatcher(LitElement) {
static styles = css`
.search-input { display: inline-flex; align-items: center; gap: 0.25rem; }
input { padding: 0.25rem 0.5rem; }
.clear-btn { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
`;
@property({ type: String, reflect: true }) placeholder: string = 'Search…';
@property({ type: Number, reflect: true }) minLength: number = 2;
@property({ type: Boolean, reflect: true }) autofocus: boolean = false;
private _query = signal('');
@query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
private _tw0 = debounce((e: Event) => (this.onSearch)(e), 300);
private _disconnectCleanups: Array<() => void> = [];
private _armListeners(): void {
this._disconnectCleanups.push(() => this._tw0.cancel());
}
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) this._armListeners();
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push((() => {
// e.g., abort an in-flight request initialized in this hook
}));
if (this.autofocus) this._refInputEl?.focus();
// Returning a function from $onMount registers a teardown — equivalent to
// a separate $onUnmount, useful when setup and teardown logic belong together.
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
}
render() {
return html`
<div class="search-input">
<input type="search" placeholder=${this.placeholder} .value=${this._query.value} @input=${(e: Event) => { ((e) => this._query.value = (e.target as HTMLInputElement).value)(e); (this._tw0)(e); }} @keydown=${(e: Event) => { ((e: Event) => { if ((e as KeyboardEvent).key !== 'Enter') return; (this.onSearch)(e); })(e); ((e: Event) => { if ((e as KeyboardEvent).key !== 'Escape') return; (this.clear)(e); })(e); }} data-rozie-ref="inputEl" />
${this._query.value.length > 0 ? html`<button class="clear-btn" aria-label="Clear" @click=${this.clear}>
×
</button>` : html`<span class="hint">${this.minLength}+ chars</span>`}</div>
`;
}
get isValid() { return this._query.value.length >= this.minLength; }
onSearch = () => {
if (this.isValid) this.dispatchEvent(new CustomEvent("search", {
detail: this._query.value,
bubbles: true,
composed: true
}));
};
clear = () => {
this._query.value = '';
this.dispatchEvent(new CustomEvent("clear", {
detail: undefined,
bubbles: true,
composed: true
}));
};
}