Appearance
Dropdown
The marquee <listeners> example — three <listener> elements. Shows the .outside(...$refs) modifier eliminating hand-rolled outside-click detection, .throttle(100).passive on a window resize, reactive r-if conditions that auto-attach/detach each listener, $watch(() => $props.open, ...) re-firing reposition when the panel mounts (the panel is r-if-gated, so $refs.panelEl is undefined at initial $onMount), multiple $onMount hooks colocated with their setup, named slots with scoped params (#trigger="{ open, toggle }"), and $model 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 the @click.outside($refs.triggerEl,$refs.panelEl) listener on document), pressing Escape (closes via the @keydown.escape listener on document), or resizing the window with the panel open (the panel's position updates, throttled to 100ms via @resize.throttle(100).passive on window).
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 = () => { $model.open = !$props.open }
const close = () => { $model.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`,
})
}
// Re-fire reposition() whenever the open transition flips on. The panel
// element is r-if-gated, so $refs.panelEl is undefined at mount time — $watch
// is the primitive that re-runs the effect after panel mount.
$watch(() => $props.open, () => {
if ($props.open) reposition()
})
// Multiple $onMount calls run in source order. Useful for colocating setup
// with the logic it serves.
$onMount(() => {
// Initial reposition only if the panel is open at mount time.
if ($props.open) reposition()
})
$onMount(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
})
// Imperative handle (Phase 21 $expose). A consumer holding a ref/handle can
// drive the dropdown imperatively without owning the `open` model state:
// toggle() flips it, close() forces it shut. Both are the same functions the
// template and <listeners> use internally — exposed verbatim to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). The `open` value is still
// the two-way `r-model:open` contract for consumers that prefer declarative
// control; the handle is the imperative complement.
$expose({ toggle, close })
</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).
r-if here means conditional attach/detach, not conditional render.
-->
<listener :target="document" @click.outside($refs.triggerEl,$refs.panelEl)="close" r-if="$props.open && $props.closeOnOutsideClick" />
<listener :target="document" @keydown.escape="close" r-if="$props.open && $props.closeOnEscape" />
<listener :target="window" @resize.throttle(100).passive="reposition" r-if="$props.open" />
</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>Compiled output
vue
<template>
<div class="dropdown" v-bind="$attrs">
<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, watch, 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`
});
};
// Re-fire reposition() whenever the open transition flips on. The panel
// element is r-if-gated, so $refs.panelEl is undefined at mount time — $watch
// is the primitive that re-runs the effect after panel mount.
onMounted(() => {
// Initial reposition only if the panel is open at mount time.
if (open.value) reposition();
});
onMounted(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
});
watch(() => open.value, () => {
if (open.value) reposition();
});
defineExpose({ toggle, close });
useOutsideClick(
[triggerElRef, panelElRef],
() => close(),
() => open.value && props.closeOnOutsideClick,
);
watchEffect((onCleanup) => {
if (!(open.value && props.closeOnEscape)) return;
const handler = ($event: KeyboardEvent) => {
if ($event.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));
});
</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>tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { ReactNode } from 'react';
import { clsx, useControllableState, useOutsideClick, useThrottledCallback } from '@rozie/runtime-react';
import './Dropdown.css';
import './Dropdown.global.css';
interface TriggerCtx { open: any; toggle: any; }
interface ChildrenCtx { close: any; }
interface DropdownProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
closeOnOutsideClick?: boolean;
closeOnEscape?: boolean;
renderTrigger?: (ctx: TriggerCtx) => ReactNode;
children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
slots?: Record<string, () => import('react').ReactNode>;
}
export interface DropdownHandle {
toggle: (...args: any[]) => any;
close: (...args: any[]) => any;
}
const Dropdown = forwardRef<DropdownHandle, DropdownProps>(function Dropdown(_props: DropdownProps, ref): JSX.Element {
const props: Omit<DropdownProps, 'closeOnOutsideClick' | 'closeOnEscape'> & { closeOnOutsideClick: boolean; closeOnEscape: boolean } = {
..._props,
closeOnOutsideClick: _props.closeOnOutsideClick ?? true,
closeOnEscape: _props.closeOnEscape ?? true,
};
const attrs: Record<string, unknown> = (() => {
const { open, closeOnOutsideClick, closeOnEscape, defaultValue, onOpenChange, defaultOpen, ...rest } = _props as DropdownProps & Record<string, unknown>;
void open; void closeOnOutsideClick; void closeOnEscape; void defaultValue; void onOpenChange; void defaultOpen;
return rest;
})();
const [open, setOpen] = useControllableState({
value: props.open,
defaultValue: props.defaultOpen ?? false,
onValueChange: props.onOpenChange,
});
const _openRef = useRef(open);
_openRef.current = open;
const triggerEl = useRef<HTMLDivElement | null>(null);
const panelEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const toggle = useCallback(() => {
setOpen(prev => !prev);
}, [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(() => {
// Initial reposition only if the panel is open at mount time.
if (_openRef.current) reposition();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
}, []);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
if (open) reposition();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieThrottledLReposition = useThrottledCallback(reposition, [open, reposition], 100);
useOutsideClick(
[triggerEl, panelEl],
close,
() => !!(open && props.closeOnOutsideClick),
);
useEffect(() => {
if (!(open && props.closeOnEscape)) return;
const _rozieHandler = ($event: KeyboardEvent) => {
if ($event.key !== 'Escape') return;
((close) as ((...args: any[]) => any))($event);
};
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);
}, [_rozieThrottledLReposition, open, reposition]);
const _rozieExposeRef = useRef({ toggle, close });
_rozieExposeRef.current = { toggle, close };
useImperativeHandle(ref, () => ({ toggle: (...args: Parameters<typeof toggle>): ReturnType<typeof toggle> => _rozieExposeRef.current.toggle(...args), close: (...args: Parameters<typeof close>): ReturnType<typeof close> => _rozieExposeRef.current.close(...args) }), []);
return (
<>
<div {...attrs} className={clsx("dropdown", (attrs.className as string | undefined))} data-rozie-s-6d6bd882="">
<div ref={triggerEl} onClick={toggle} data-rozie-s-6d6bd882="">
{(props.renderTrigger ?? props.slots?.['trigger'])?.({ open, toggle })}
</div>
{(open) && <div ref={panelEl} className={"dropdown-panel"} role="menu" data-rozie-s-6d6bd882="">
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ close }) : (props.children ?? props.slots?.[''])}
</div>}</div>
</>
);
});
export default Dropdown;svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
const throttledLReposition = (() => {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
if (now - lastCall < 100) return;
lastCall = now;
(reposition as (...a: any[]) => any)(...args);
};
})();
import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';
interface Props {
open?: boolean;
closeOnOutsideClick?: boolean;
closeOnEscape?: boolean;
trigger?: Snippet<[{ open: any; toggle: any }]>;
children?: Snippet<[{ close: any }]>;
snippets?: Record<string, any>;
[key: string]: unknown;
}
let {
open = $bindable(false),
closeOnOutsideClick = true,
closeOnEscape = true,
trigger: __triggerProp,
children: __childrenProp,
snippets,
...__rozieAttrs
}: Props = $props();
const trigger = $derived(__triggerProp ?? snippets?.trigger);
const children = $derived(__childrenProp ?? snippets?.children);
let triggerEl = $state<HTMLElement | undefined>(undefined);
let panelEl = $state<HTMLElement | undefined>(undefined);
export const toggle = () => {
open = !open;
};
export 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`
});
};
// Re-fire reposition() whenever the open transition flips on. The panel
// element is r-if-gated, so $refs.panelEl is undefined at mount time — $watch
// is the primitive that re-runs the effect after panel mount.
onMount(() => {
// Initial reposition only if the panel is open at mount time.
if (open) reposition();
});
onMount(() => {
// Example of integrating a vanilla JS library — $refs gives direct DOM access.
// new Popper($refs.triggerEl, $refs.panelEl, { placement: 'bottom-start' })
});
let __rozieWatchInitial_0 = true;
$effect(() => { (() => open)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
if (open) reposition();
})(); }); });
$effect(() => {
if (!(open && closeOnOutsideClick)) return;
const handler = ($event: MouseEvent) => {
const target = $event.target as Node;
if (triggerEl?.contains(target) || panelEl?.contains(target)) return;
close();
};
let attached = false;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
document.addEventListener('click', handler);
attached = true;
}, 0);
return () => {
cancelled = true;
clearTimeout(timer);
if (attached) document.removeEventListener('click', handler);
};
});
$effect(() => {
if (!(open && closeOnEscape)) return;
const handler = ($event: KeyboardEvent) => {
if ($event.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);
});
</script>
<div {...__rozieAttrs} class={["dropdown", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-6d6bd882><div bind:this={triggerEl} onclick={toggle} data-rozie-s-6d6bd882>{@render trigger?.({ open, toggle })}</div>{#if open}<div bind:this={panelEl} class="dropdown-panel" role="menu" data-rozie-s-6d6bd882>{@render children?.({ close })}</div>{/if}</div>
<style>
:global {
.dropdown[data-rozie-s-6d6bd882] { position: relative; display: inline-block; }
.dropdown-panel[data-rozie-s-6d6bd882] {
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>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
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" #rozieSpread_0 #rozieListenersTarget_1>
<div #triggerEl (click)="toggle()">
<ng-container *ngTemplateOutlet="(triggerTpl ?? templates()?.['trigger']); context: { $implicit: { open: open(), toggle: toggle }, open: open(), toggle: toggle }" />
</div>
@if (open()) {
<div #panelEl class="dropdown-panel" role="menu">
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); 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;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Dropdown),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
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>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieWatchInitial_0 = true;
constructor() {
const renderer = inject(Renderer2);
effect((onCleanup) => {
if (!(this.open() && this.closeOnOutsideClick())) return;
const handler = ($event: MouseEvent) => {
const target = $event.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 = ($event: KeyboardEvent) => {
if ($event.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);
});
effect(() => { const __watchVal = (() => this.open())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
if (this.open()) this.reposition();
})(); }); });
}
ngAfterViewInit() {
// Initial reposition only if the panel is open at mount time.
if (this.open()) this.reposition();
}
toggle = () => {
this.open.set(!this.open()), this.__rozieCvaOnChange(!this.open());
};
close = () => {
this.open.set(false), this.__rozieCvaOnChange(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`
});
};
private __rozieCvaOnChange: (v: boolean) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: boolean | null): void {
this.open.set(v ?? false);
}
registerOnChange(fn: (v: boolean) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: Dropdown,
_ctx: unknown,
): _ctx is TriggerCtx | DefaultCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
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);
};
})();
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 = [];
});
}
});
}
export default Dropdown;tsx
import type { JSX } from 'solid-js';
import { Show, children, createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, createOutsideClick, createThrottledHandler } from '@rozie/runtime-solid';
__rozieInjectStyle('Dropdown-6d6bd882', `.dropdown[data-rozie-s-6d6bd882] { position: relative; display: inline-block; }
.dropdown-panel[data-rozie-s-6d6bd882] {
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);
}
:root {
--rozie-dropdown-z: 1000;
}`);
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;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: DropdownHandle) => void;
}
export interface DropdownHandle {
toggle: (...args: any[]) => any;
close: (...args: any[]) => any;
}
export default function Dropdown(_props: DropdownProps): JSX.Element {
const _merged = mergeProps({ closeOnOutsideClick: true, closeOnEscape: true }, _props);
const [local, attrs] = splitProps(_merged, ['open', 'closeOnOutsideClick', 'closeOnEscape', 'children', 'ref']);
const resolved = children(() => local.children);
onMount(() => { local.ref?.({ toggle, close }); });
const [open, setOpen] = createControllableSignal<boolean>(_props as unknown as Record<string, unknown>, 'open', false);
onMount(() => {
// Initial reposition only if the panel is open at mount time.
if (open()) reposition();
});
onMount(() => {});
createEffect(on(() => (() => open())(), (v) => untrack(() => (() => {
if (open()) reposition();
})()), { defer: true }));
let triggerElRef: HTMLElement | null = null;
let panelElRef: HTMLElement | null = null;
function toggle() {
setOpen(!open());
}
function close() {
setOpen(false);
}
function reposition() {
if (!panelElRef || !triggerElRef) return;
const rect = triggerElRef.getBoundingClientRect();
Object.assign(panelElRef.style, {
top: `${rect.bottom}px`,
left: `${rect.left}px`
});
}
// Re-fire reposition() whenever the open transition flips on. The panel
// element is r-if-gated, so $refs.panelEl is undefined at mount time — $watch
// is the primitive that re-runs the effect after panel mount.
const _rozieThrottleLReposition = createThrottledHandler(reposition, 100);
createOutsideClick(
[() => triggerElRef, () => panelElRef],
close,
() => open() && local.closeOnOutsideClick,
);
createEffect(() => {
if (!(open() && local.closeOnEscape)) return;
const _rozieHandler = ($event: KeyboardEvent) => {
if ($event.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 (
<>
<div {...attrs} class={"dropdown" + (((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-6d6bd882="">
<div ref={(el) => { triggerElRef = el as HTMLElement; }} onClick={toggle} data-rozie-s-6d6bd882="">
{(_props.triggerSlot ?? _props.slots?.['trigger'])?.({ open: open(), toggle })}
</div>
{<Show when={open()}><div ref={(el) => { panelElRef = el as HTMLElement; }} class={"dropdown-panel"} role="menu" data-rozie-s-6d6bd882="">
{typeof local.children === 'function' ? (local.children as (s: any) => any)({ close }) : resolved()}
</div></Show>}</div>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { attachOutsideClickListener, createLitControllableProperty, injectGlobalStyles, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
interface RozieTriggerSlotCtx {
open: unknown;
toggle: unknown;
}
@customElement('rozie-dropdown')
export default class Dropdown extends SignalWatcher(LitElement) {
static styles = css`
.dropdown[data-rozie-s-6d6bd882] { position: relative; display: inline-block; }
.dropdown-panel[data-rozie-s-6d6bd882] {
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;
private __rozieWatchInitial_0 = true;
@state() private _hasSlotTrigger = false;
@queryAssignedElements({ slot: 'trigger', flatten: true }) private _slotTriggerElements!: Element[];
@property({ attribute: false }) trigger?: (scope: { open: unknown; toggle: unknown }) => unknown;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@property({ attribute: false }) __rozieDefaultSlot__?: (scope: { close: unknown }) => unknown;
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 {
const _u0 = attachOutsideClickListener([() => this._refTriggerEl, () => this._refPanelEl], ($event) => { ((this.close) as (...args: any[]) => any)($event); }, () => (this.open && this.closeOnOutsideClick));
this._disconnectCleanups.push(_u0);
const _lh1 = ($event: KeyboardEvent) => { if (!(this.open && this.closeOnEscape)) return; if ($event.key !== 'Escape') return; ((this.close) as (...args: any[]) => any)($event); };
document.addEventListener('keydown', _lh1, undefined);
this._disconnectCleanups.push(() => document.removeEventListener('keydown', _lh1, undefined));
const _lh2 = (() => { let last = 0; return ($event: Event) => { if (!(this.open)) return; const now = Date.now(); if (now - last < 100) return; last = now; ((this.reposition) as (...args: any[]) => any)($event); }; })();
window.addEventListener('resize', _lh2, { passive: true });
this._disconnectCleanups.push(() => window.removeEventListener('resize', _lh2, undefined));
{
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 {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotTrigger = Array.from(this.children).some((el) => el.getAttribute('slot') === 'trigger');
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.open)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
if (this.open) this.reposition();
})(); }); }));
// Initial reposition only if the panel is open at mount time.
if (this.open) this.reposition();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
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" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-6d6bd882>
<div @click=${this.toggle} data-rozie-ref="triggerEl" data-rozie-s-6d6bd882>
${this.trigger !== undefined ? this.trigger({open: this.open, toggle: this.toggle}) : html`<slot name="trigger" data-rozie-params=${(() => { try { return JSON.stringify({open: this.open}); } catch { return '{}'; } })()} @rozie-trigger-toggle=${($event: CustomEvent) => ((this.toggle) as (...args: any[]) => any)($event.detail)}></slot>`}
</div>
${this.open ? html`<div class="dropdown-panel" role="menu" data-rozie-ref="panelEl" data-rozie-s-6d6bd882>
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({close: this.close}) : html`<slot @rozie-default-close=${($event: CustomEvent) => ((this.close) as (...args: any[]) => any)($event.detail)}></slot>`}
</div>` : nothing}</div>
`;
}
toggle = () => {
this._openControllable.write(!this.open);
};
close = () => {
this._openControllable.write(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.notifyPropertyWrite(v); }
/**
* 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>(['open', 'close-on-outside-click', 'closeonoutsideclick', 'close-on-escape', 'closeonescape']);
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;
}
}
injectGlobalStyles('rozie-dropdown-global', `
:root {
--rozie-dropdown-z: 1000;
}
`);