Appearance
Dropdown
The marquee <listeners> example. Shows the .outside(...$refs) modifier eliminating hand-rolled outside-click detection, .throttle(100).passive on a window resize, reactive when predicates that auto-attach/detach listeners, multiple $onMount hooks colocated with their setup, named slots with scoped params (#trigger="{ open, toggle }"), and $props writes flowing through to each target's two-way pattern because open is declared model: true.
Live demo
Click the trigger to open. Then try: clicking outside the panel (closes via .outside($refs.triggerEl, $refs.panelEl)), pressing Escape (closes via document:keydown.escape), or resizing the window with the panel open (the panel's position updates, throttled to 100ms via .throttle(100).passive).
Source — Dropdown.rozie
rozie
<!--
Dropdown.rozie
Demonstrates the marquee Rozie features for component-library work:
- <listeners> block with reactive `when` conditions
- .outside(...refs) modifier — eliminates hand-rolled isOutside checks
- .throttle(ms) parameterized modifier
- Template refs derived from `ref="..."` (no separate <refs> block)
- Multiple lifecycle hooks ($onMount used twice)
- Named slot with slot params (consumer uses `#name="{ data }"` shorthand)
- Default slot with slot params
- $props writes (compiles to parent-update path because `model: true`)
-->
<rozie name="Dropdown">
<props>
{
open: { type: Boolean, default: false, model: true },
closeOnOutsideClick: { type: Boolean, default: true },
closeOnEscape: { type: Boolean, default: true },
}
</props>
<script>
const toggle = () => { $props.open = !$props.open }
const close = () => { $props.open = false }
const reposition = () => {
if (!$refs.panelEl || !$refs.triggerEl) return
const rect = $refs.triggerEl.getBoundingClientRect()
Object.assign($refs.panelEl.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`,
})
}
// Multiple $onMount calls run in source order. Useful for colocating setup
// with the logic it serves.
$onMount(() => {
reposition()
})
$onMount(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
})
</script>
<listeners>
{
// .outside takes ref args; fires only when the click target is outside ALL listed refs.
// Compiler emits the per-target wiring (Vue watchEffect, React useEffect with auto-deps,
// Svelte $effect, Angular effect + Renderer2.listen + DestroyRef cleanup).
"document:click.outside($refs.triggerEl, $refs.panelEl)": {
when: "$props.open && $props.closeOnOutsideClick",
handler: close,
},
"document:keydown.escape": {
when: "$props.open && $props.closeOnEscape",
handler: close,
},
"window:resize.throttle(100).passive": {
when: "$props.open",
handler: reposition,
},
}
</listeners>
<template>
<div class="dropdown">
<div ref="triggerEl" @click="toggle">
<slot name="trigger" :open="$props.open" :toggle="toggle" />
</div>
<div r-if="$props.open" ref="panelEl" class="dropdown-panel" role="menu">
<slot :close="close" />
</div>
</div>
</template>
<style>
.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* Unscoped escape hatch — anything inside :root { } is emitted globally. */
:root {
--rozie-dropdown-z: 1000;
}
</style>
</rozie>Vue output
vue
<template>
<div class="dropdown">
<div ref="triggerElRef" @click="toggle">
<slot name="trigger" :open="open" :toggle="toggle"></slot>
</div>
<div v-if="open" ref="panelElRef" class="dropdown-panel" role="menu">
<slot :close="close"></slot>
</div></div>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect } from 'vue';
import { throttle, useOutsideClick } from '@rozie/runtime-vue';
const props = withDefaults(
defineProps<{ closeOnOutsideClick?: boolean; closeOnEscape?: boolean }>(),
{ closeOnOutsideClick: true, closeOnEscape: true }
);
const open = defineModel<boolean>('open', { default: false });
defineSlots<{
trigger(props: { open: any; toggle: any }): any;
default(props: { close: any }): any;
}>();
const triggerElRef = ref<HTMLElement>();
const panelElRef = ref<HTMLElement>();
const toggle = () => {
open.value = !open.value;
};
const close = () => {
open.value = false;
};
const reposition = () => {
if (!panelElRef.value || !triggerElRef.value) return;
const rect = triggerElRef.value.getBoundingClientRect();
Object.assign(panelElRef.value.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
};
// Multiple $onMount calls run in source order. Useful for colocating setup
// with the logic it serves.
onMounted(() => {
reposition();
});
onMounted(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
});
useOutsideClick(
[triggerElRef, panelElRef],
() => close(),
() => open.value && props.closeOnOutsideClick,
);
watchEffect((onCleanup) => {
if (!(open.value && props.closeOnEscape)) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', handler);
onCleanup(() => document.removeEventListener('keydown', handler));
});
const throttledLReposition = throttle(reposition, 100);
watchEffect((onCleanup) => {
if (!(open.value)) return;
window.addEventListener('resize', throttledLReposition, { passive: true });
onCleanup(() => window.removeEventListener('resize', throttledLReposition, { passive: true }));
});
</script>
<style scoped>
.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
</style>
<style>
:root {
--rozie-dropdown-z: 1000;
}
</style>React output
tsx
import { useCallback, useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { useControllableState, useOutsideClick, useThrottledCallback } from '@rozie/runtime-react';
import styles from './Dropdown.module.css';
import './Dropdown.global.css';
interface TriggerCtx { open: any; toggle: any; }
interface ChildrenCtx { close: any; }
interface DropdownProps {
open?: boolean;
defaultValue?: boolean;
onOpenChange?: (open: boolean) => void;
closeOnOutsideClick?: boolean;
closeOnEscape?: boolean;
renderTrigger?: (ctx: TriggerCtx) => ReactNode;
children?: (ctx: ChildrenCtx) => ReactNode;
}
export default function Dropdown(_props: DropdownProps): JSX.Element {
const props: DropdownProps = {
..._props,
closeOnOutsideClick: _props.closeOnOutsideClick ?? true,
closeOnEscape: _props.closeOnEscape ?? true,
};
const [open, setOpen] = useControllableState({
value: props.open,
defaultValue: props.defaultValue ?? false,
onValueChange: props.onOpenChange,
});
const triggerEl = useRef<HTMLDivElement | null>(null);
const panelEl = useRef<HTMLDivElement | null>(null);
const toggle = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const close = useCallback(() => {
setOpen(false);
}, [setOpen]);
const reposition = useCallback(() => {
if (!panelEl.current || !triggerEl.current) return;
const rect = triggerEl.current.getBoundingClientRect();
Object.assign(panelEl.current.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
}, []);
useEffect(() => {
reposition();
}, [reposition]);
useEffect(() => {
}, []);
const _rozieThrottledLReposition = useThrottledCallback(reposition, [open, reposition], 100);
useOutsideClick(
[triggerEl, panelEl],
close,
() => open && props.closeOnOutsideClick,
);
useEffect(() => {
if (!(open && props.closeOnEscape)) return;
const _rozieHandler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close(e);
};
document.addEventListener('keydown', _rozieHandler);
return () => document.removeEventListener('keydown', _rozieHandler);
}, [close, open, props.closeOnEscape]);
useEffect(() => {
if (!(open)) return;
window.addEventListener('resize', _rozieThrottledLReposition, { passive: true });
return () => window.removeEventListener('resize', _rozieThrottledLReposition, { passive: true });
}, [_rozieThrottledLReposition, open, reposition]);
return (
<>
<div className={styles.dropdown}>
<div ref={triggerEl} onClick={toggle}>
{props.renderTrigger?.({ open, toggle })}
</div>
{(open) && <div ref={panelEl} className={styles["dropdown-panel"]} role="menu">
{props.children?.({ close })}
</div>}</div>
</>
);
}Svelte output
svelte
<script lang="ts">
const throttledLReposition = (() => {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
if (now - lastCall < 100) return;
lastCall = now;
(reposition)(...args);
};
})();
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
closeOnOutsideClick?: boolean;
closeOnEscape?: boolean;
trigger?: Snippet<[any, any]>;
children?: Snippet<[any]>;
}
let {
open = $bindable(false),
closeOnOutsideClick = true,
closeOnEscape = true,
trigger,
children,
}: Props = $props();
let triggerEl = $state<HTMLElement | undefined>(undefined);
let panelEl = $state<HTMLElement | undefined>(undefined);
const toggle = () => {
open = !open;
};
const close = () => {
open = false;
};
const reposition = () => {
if (!panelEl || !triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
Object.assign(panelEl.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
};
// Multiple $onMount calls run in source order. Useful for colocating setup
// with the logic it serves.
$effect(() => {
reposition();
});
$effect(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
});
$effect(() => {
if (!(open && closeOnOutsideClick)) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (triggerEl?.contains(target) || panelEl?.contains(target)) return;
close();
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
});
$effect(() => {
if (!(open && closeOnEscape)) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
$effect(() => {
if (!(open)) return;
window.addEventListener('resize', throttledLReposition, { passive: true });
return () => window.removeEventListener('resize', throttledLReposition, { passive: true });
});
</script>
<div class="dropdown">
<div bind:this={triggerEl} onclick={toggle}>
{@render trigger?.(open, toggle)}
</div>
{#if open}<div bind:this={panelEl} class="dropdown-panel" role="menu">
{@render children?.(close)}
</div>{/if}</div>
<style>
.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
:global(:root) {
--rozie-dropdown-z: 1000;
}
</style>Angular output
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, effect, inject, input, model, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
interface TriggerCtx {
$implicit: { open: any; toggle: any };
open: any;
toggle: any;
}
interface DefaultCtx {
$implicit: { close: any };
close: any;
}
@Component({
selector: 'rozie-dropdown',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="dropdown">
<div #triggerEl (click)="toggle($event)">
<ng-container *ngTemplateOutlet="triggerTpl; context: { $implicit: { open: open(), toggle: toggle }, open: open(), toggle: toggle }" />
</div>
@if (open()) {
<div #panelEl class="dropdown-panel" role="menu">
<ng-container *ngTemplateOutlet="defaultTpl; context: { $implicit: { close: close }, close: close }" />
</div>
}</div>
`,
styles: [`
.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
::ng-deep :root {
--rozie-dropdown-z: 1000;
}
`],
})
export class Dropdown {
open = model<boolean>(false);
closeOnOutsideClick = input<boolean>(true);
closeOnEscape = input<boolean>(true);
triggerEl = viewChild<ElementRef<HTMLDivElement>>('triggerEl');
panelEl = viewChild<ElementRef<HTMLDivElement>>('panelEl');
@ContentChild('trigger', { read: TemplateRef }) triggerTpl?: TemplateRef<TriggerCtx>;
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
constructor() {
const renderer = inject(Renderer2);
effect((onCleanup) => {
if (!(this.open() && this.closeOnOutsideClick())) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (this.triggerEl()?.nativeElement?.contains(target) || this.panelEl()?.nativeElement?.contains(target)) return;
this.close();
};
const unlisten = renderer.listen('document', 'click', handler);
onCleanup(unlisten);
});
effect((onCleanup) => {
if (!(this.open() && this.closeOnEscape())) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
this.close();
};
const unlisten = renderer.listen('document', 'keydown', handler);
onCleanup(unlisten);
});
effect((onCleanup) => {
if (!(this.open())) return;
const unlisten = renderer.listen('window', 'resize', this.throttledLReposition);
onCleanup(unlisten);
});
this.reposition();
}
toggle = () => {
this.open.set(!this.open());
};
close = () => {
this.open.set(false);
};
reposition = () => {
if (!this.panelEl()?.nativeElement || !this.triggerEl()?.nativeElement) return;
const rect = this.triggerEl()!.nativeElement.getBoundingClientRect();
Object.assign(this.panelEl()!.nativeElement.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
};
static ngTemplateContextGuard(
_dir: Dropdown,
_ctx: unknown,
): _ctx is TriggerCtx | DefaultCtx {
return true;
}
private throttledLReposition = (() => {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
if (now - lastCall < 100) return;
lastCall = now;
(this.reposition as (...a: any[]) => any)(...args);
};
})();
}
export default Dropdown;Solid output
tsx
import type { JSX } from 'solid-js';
import { Show, children, createEffect, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { createControllableSignal, createOutsideClick, createThrottledHandler } from '@rozie/runtime-solid';
interface TriggerSlotCtx { open: any; toggle: any; }
interface DropdownProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
closeOnOutsideClick?: boolean;
closeOnEscape?: boolean;
triggerSlot?: (ctx: TriggerSlotCtx) => JSX.Element;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
}
export default function Dropdown(_props: DropdownProps): JSX.Element {
const _merged = mergeProps({ closeOnOutsideClick: true, closeOnEscape: true }, _props);
const [local, rest] = splitProps(_merged, ['open', 'closeOnOutsideClick', 'closeOnEscape', 'children']);
const resolved = children(() => local.children);
const [open, setOpen] = createControllableSignal(_props as Record<string, unknown>, 'open', false);
onMount(() => {
reposition();
});
onMount(() => {});
let triggerElRef: HTMLElement | null = null;
let panelElRef: HTMLElement | null = null;
const toggle = () => {
setOpen(!open());
};
const close = () => {
setOpen(false);
};
const reposition = () => {
if (!panelElRef || !triggerElRef) return;
const rect = triggerElRef.getBoundingClientRect();
Object.assign(panelElRef.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
};
// Multiple $onMount calls run in source order. Useful for colocating setup
// with the logic it serves.
const _rozieThrottleLReposition = createThrottledHandler(reposition, 100);
createOutsideClick(
[() => triggerElRef, () => panelElRef],
close,
() => open() && local.closeOnOutsideClick,
);
createEffect(() => {
if (!(open() && local.closeOnEscape)) return;
const _rozieHandler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', _rozieHandler);
onCleanup(() => document.removeEventListener('keydown', _rozieHandler));
});
createEffect(() => {
if (!(open())) return;
window.addEventListener('resize', _rozieThrottleLReposition, { passive: true } as AddEventListenerOptions);
onCleanup(() => window.removeEventListener('resize', _rozieThrottleLReposition, { passive: true } as AddEventListenerOptions));
});
return (
<>
<style>{`.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}`}</style>
<style>{`:root {
--rozie-dropdown-z: 1000;
}`}</style>
<>
<div class={"dropdown"}>
<div ref={(el) => { triggerElRef = el as HTMLElement; }} onClick={toggle}>
{_props.triggerSlot?.({ open: open(), toggle })}
</div>
{<Show when={open()}><div ref={(el) => { panelElRef = el as HTMLElement; }} class={"dropdown-panel"} role="menu">
{resolved()}
</div></Show>}</div>
</>
</>
);
}Lit output
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { attachOutsideClickListener, createLitControllableProperty, injectGlobalStyles } from '@rozie/runtime-lit';
interface RozieTriggerSlotCtx {
open: unknown;
toggle: unknown;
}
interface RozieDefaultSlotCtx {
close: unknown;
}
@customElement('rozie-dropdown')
export default class Dropdown extends SignalWatcher(LitElement) {
static styles = css`
.dropdown { position: relative; display: inline-block; }
.dropdown-panel {
position: fixed;
z-index: var(--rozie-dropdown-z, 1000);
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
`;
@property({ type: Boolean, attribute: 'open' }) _open_attr: boolean = false;
private _openControllable = createLitControllableProperty<boolean>({ host: this, eventName: 'open-change', defaultValue: false, initialControlledValue: undefined });
@property({ type: Boolean, reflect: true }) closeOnOutsideClick: boolean = true;
@property({ type: Boolean, reflect: true }) closeOnEscape: boolean = true;
@query('[data-rozie-ref="triggerEl"]') private _refTriggerEl!: HTMLElement;
@query('[data-rozie-ref="panelEl"]') private _refPanelEl!: HTMLElement;
@state() private _hasSlotTrigger = false;
@queryAssignedElements({ slot: 'trigger', flatten: true }) private _slotTriggerElements!: Element[];
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
private _armListeners(): void {
const _u0 = attachOutsideClickListener([() => this._refTriggerEl, () => this._refPanelEl], (e) => { (this.close)(e); }, () => (this.open && this.closeOnOutsideClick));
this._disconnectCleanups.push(_u0);
const _lh1 = (e: Event) => { if (!(this.open && this.closeOnEscape)) return; if ((e as KeyboardEvent).key !== 'Escape') return; (this.close)(e); };
document.addEventListener('keydown', _lh1, undefined);
this._disconnectCleanups.push(() => document.removeEventListener('keydown', _lh1, undefined));
const _lh2 = (() => { let last = 0; return (e: Event) => { if (!(this.open)) return; const now = Date.now(); if (now - last < 100) return; last = now; (this.reposition)(e); }; })();
window.addEventListener('resize', _lh2, { passive: true });
this._disconnectCleanups.push(() => window.removeEventListener('resize', _lh2, undefined));
this.addEventListener('rozie-trigger-toggle', (e) => { (this.toggle)((e as CustomEvent).detail); });
this.addEventListener('rozie-default-close', (e) => { (this.close)((e as CustomEvent).detail); });
{
const slotEl = this.shadowRoot?.querySelector('slot[name="trigger"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotTrigger = this._slotTriggerElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) this._armListeners();
}
firstUpdated(): void {
this._armListeners();
this.reposition();
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'open') this._openControllable.notifyAttributeChange(value !== null);
}
render() {
return html`
<div class="dropdown">
<div @click=${this.toggle} data-rozie-ref="triggerEl">
<slot name="trigger" data-rozie-params=${(() => { try { return JSON.stringify({open: this.open}); } catch { return '{}'; } })()}></slot>
</div>
${this.open ? html`<div class="dropdown-panel" role="menu" data-rozie-ref="panelEl">
<slot></slot>
</div>` : nothing}</div>
`;
}
toggle = () => {
this.open = !this.open;
};
close = () => {
this.open = false;
};
reposition = () => {
if (!this._refPanelEl || !this._refTriggerEl) return;
const rect = this._refTriggerEl.getBoundingClientRect();
Object.assign(this._refPanelEl.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
};
get open(): boolean { return this._openControllable.read(); }
set open(v: boolean) { this._openControllable.write(v); }
}
injectGlobalStyles('rozie-dropdown-global', `
:root {
--rozie-dropdown-z: 1000;
}
`);