Appearance
SearchInput
Demonstrates r-model on a form input, the <data> block, $computed deriving from $data, $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>Compiled output
vue
<template>
<div class="search-input" v-bind="$attrs">
<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>tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clsx, useDebouncedCallback } from '@rozie/runtime-react';
import './SearchInput.css';
interface SearchInputProps {
placeholder?: string;
minLength?: number;
autofocus?: boolean;
onSearch?: (...args: any[]) => void;
onClear?: (...args: any[]) => void;
}
export default function SearchInput(_props: SearchInputProps): JSX.Element {
const props: Omit<SearchInputProps, 'placeholder' | 'minLength' | 'autofocus'> & { placeholder: string; minLength: number; autofocus: boolean } = {
..._props,
placeholder: _props.placeholder ?? 'Search…',
minLength: _props.minLength ?? 2,
autofocus: _props.autofocus ?? false,
};
const attrs: Record<string, unknown> = (() => {
const { placeholder, minLength, autofocus, onSearch, onClear, ...rest } = _props as SearchInputProps & Record<string, unknown>;
void placeholder; void minLength; void autofocus; void onSearch; void onClear;
return rest;
})();
const _autofocusRef = useRef(props.autofocus);
_autofocusRef.current = props.autofocus;
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 (_autofocusRef.current) 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 () => {
// e.g., abort an in-flight request initialized in this hook
};
}, []);
const _rozieDebouncedOnSearch = useDebouncedCallback(onSearch, [onSearch], 300);
return (
<>
<div {...attrs} className={clsx("search-input", (attrs.className as string | undefined))} data-rozie-s-8bbc4a60="">
<input ref={inputEl} type="search" placeholder={props.placeholder} value={query} onChange={e => setQuery(e.target.value)} onInput={_rozieDebouncedOnSearch} onKeyDown={($event) => { (($event) => { if ($event.key !== 'Enter') return; ((onSearch) as ((...args: any[]) => any))($event); })($event); (($event) => { if ($event.key !== 'Escape') return; ((clear) as ((...args: any[]) => any))($event); })($event); }} data-rozie-s-8bbc4a60="" />
{(query.length > 0) ? <button className={"clear-btn"} aria-label="Clear" onClick={clear} data-rozie-s-8bbc4a60="">
×
</button> : <span className={"hint"} data-rozie-s-8bbc4a60="">{props.minLength}+ chars</span>}</div>
</>
);
}svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import { onMount } from 'svelte';
interface Props {
placeholder?: string;
minLength?: number;
autofocus?: boolean;
onsearch?: (...args: unknown[]) => void;
onclear?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let {
placeholder = 'Search…',
minLength = 2,
autofocus = false,
onsearch,
onclear,
...__rozieAttrs
}: 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);
onMount(() => {
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 as (...a: any[]) => any)(...args), 300);
};
})();
</script>
<div {...__rozieAttrs} class={["search-input", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-8bbc4a60><input bind:this={inputEl} type="search" placeholder={placeholder} bind:value={query} oninput={debouncedOnSearch} onkeydown={($event) => { (() => { (($event) => { if ($event.key !== 'Enter') return; (onSearch as (...a: any[]) => any)($event); })($event); })(); (() => { (($event) => { if ($event.key !== 'Escape') return; (clear as (...a: any[]) => any)($event); })($event); })(); }} data-rozie-s-8bbc4a60 />{#if query.length > 0}<button class="clear-btn" aria-label="Clear" onclick={clear} data-rozie-s-8bbc4a60>
×
</button>{:else}<span class="hint" data-rozie-s-8bbc4a60>{minLength}+ chars</span>{/if}</div>
<style>
:global {
.search-input[data-rozie-s-8bbc4a60] { display: inline-flex; align-items: center; gap: 0.25rem; }
input[data-rozie-s-8bbc4a60] { padding: 0.25rem 0.5rem; }
.clear-btn[data-rozie-s-8bbc4a60] { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint[data-rozie-s-8bbc4a60] { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createRozieAttrApplier, createRozieHostAttrsReader } from '@rozie/runtime-angular';
@Component({
selector: 'rozie-search-input',
standalone: true,
imports: [FormsModule],
template: `
<div class="search-input" #rozieSpread_0 #rozieListenersTarget_1>
<input #inputEl type="search" [placeholder]="placeholder()" [ngModel]="query()" (ngModelChange)="query.set($event)" [ngModelOptions]="{standalone: true}" (input)="debouncedOnSearch_2()" (keydown)="_merged_keydown_3($event)" />
@if (query().length > 0) {
<button class="clear-btn" aria-label="Clear" (click)="_clear()">
×
</button>
} @else {
<span class="hint">{{ minLength() }}+ chars</span>
}</div>
`,
styles: [`
:host(rozie-search-input) { display: contents; }
.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>();
private __rozieDestroyRef = inject(DestroyRef);
ngAfterViewInit() {
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.
this.__rozieDestroyRef.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 rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));
private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
private debouncedOnSearch_2 = (() => {
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_4 = ($event: any) => {
if ($event.key !== 'Enter') return;
this.onSearch();
};
private _guarded_clear_5 = ($event: any) => {
if ($event.key !== 'Escape') return;
this._clear();
};
private _merged_keydown_3 = ($event: any) => {
this._guardedOnSearch_4($event);
this._guarded_clear_5($event);
};
}
export default SearchInput;tsx
import type { JSX } from 'solid-js';
import { Show, createMemo, createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createDebouncedHandler } from '@rozie/runtime-solid';
__rozieInjectStyle('SearchInput-8bbc4a60', `.search-input[data-rozie-s-8bbc4a60] { display: inline-flex; align-items: center; gap: 0.25rem; }
input[data-rozie-s-8bbc4a60] { padding: 0.25rem 0.5rem; }
.clear-btn[data-rozie-s-8bbc4a60] { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint[data-rozie-s-8bbc4a60] { color: rgba(0, 0, 0, 0.4); font-size: 0.85em; }`);
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, attrs] = splitProps(_merged, ['placeholder', 'minLength', 'autofocus', 'onSearch', 'onClear']);
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;
function onSearch() {
if (isValid()) _props.onSearch?.(query());
}
function clear() {
setQuery('');
_props.onClear?.();
}
const _rozieDebouncedOnSearch = createDebouncedHandler(onSearch, 300);
return (
<>
<div {...attrs} class={"search-input" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-8bbc4a60="">
<input type="search" ref={(el) => { inputElRef = el as HTMLElement; }} placeholder={local.placeholder} value={query()} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { (e => setQuery(e.currentTarget.value))($event); _rozieDebouncedOnSearch($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { (($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { if ($event.key !== 'Enter') return; onSearch(); })($event); (($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { if ($event.key !== 'Escape') return; clear(); })($event); }} data-rozie-s-8bbc4a60="" />
{<Show when={query().length > 0} fallback={<span class={"hint"} data-rozie-s-8bbc4a60="">{local.minLength}+ chars</span>}><button aria-label="Clear" class={"clear-btn"} onClick={clear} data-rozie-s-8bbc4a60="">
×
</button></Show>}</div>
</>
);
}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, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
@customElement('rozie-search-input')
export default class SearchInput extends SignalWatcher(LitElement) {
static styles = css`
:host{display:contents}
.search-input[data-rozie-s-8bbc4a60] { display: inline-flex; align-items: center; gap: 0.25rem; }
input[data-rozie-s-8bbc4a60] { padding: 0.25rem 0.5rem; }
.clear-btn[data-rozie-s-8bbc4a60] { background: none; border: none; cursor: pointer; font-size: 1.25rem; }
.hint[data-rozie-s-8bbc4a60] { 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(($event: Event) => ((this.onSearch) as (...args: any[]) => any)($event), 300);
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
this._disconnectCleanups.push(() => this._tw0.cancel());
}
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; 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();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
render() {
return html`
<div class="search-input" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-8bbc4a60>
<input type="search" placeholder=${this.placeholder} .value=${this._query.value} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { (($event: Event) => this._query.value = ($event.target as HTMLInputElement).value)($event); (this._tw0)($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { (($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { if ($event.key !== 'Enter') return; ((this.onSearch) as (...args: any[]) => any)($event); })($event); (($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { if ($event.key !== 'Escape') return; ((this.clear) as (...args: any[]) => any)($event); })($event); }} data-rozie-ref="inputEl" data-rozie-s-8bbc4a60 />
${this._query.value.length > 0 ? html`<button class="clear-btn" aria-label="Clear" @click=${this.clear} data-rozie-s-8bbc4a60>
×
</button>` : html`<span class="hint" data-rozie-s-8bbc4a60>${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
}));
};
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', 'placeholder', 'min-length', 'minlength', 'autofocus']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}