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, ...rest } = _props as SearchInputProps & Record<string, unknown>;
void placeholder; void minLength; void autofocus;
return rest;
})();
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 () => {
// e.g., abort an in-flight request initialized in this hook
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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';
@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: [`
.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 = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
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']);
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) => { (e => setQuery(e.currentTarget.value))($event); _rozieDebouncedOnSearch($event); }} onKeyDown={($event) => { (($event) => { if ($event.key !== 'Enter') return; onSearch(); })($event); (($event) => { 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`
.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) => { (($event) => this._query.value = ($event.target as HTMLInputElement).value)($event); (this._tw0)($event); }} @keydown=${($event: KeyboardEvent) => { (($event: KeyboardEvent) => { if ($event.key !== 'Enter') return; ((this.onSearch) as (...args: any[]) => any)($event); })($event); (($event: KeyboardEvent) => { 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['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;
}
}