Skip to content

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;
}
`);

Pre-v1.0 — internal monorepo.