Skip to content

Modal

The heaviest example in this set. Demonstrates the <listeners> block (a <listener :target="document" @keydown.escape="close" r-if="..." /> element), the .self modifier on a template-level backdrop click, multiple colocated $onMount / $onUnmount hooks, $emit for parent-controlled close, named slots with scoped params, and r-if (full unmount, not just hidden — distinct from the r-if conditional-attach on the <listener>).

Live demo

Click the button to open the actual examples/Modal.rozie. Press Escape, click the backdrop, or hit the × button to close. The Modal renders whatever you pass into its default slot — the demo below passes plain paragraph content.

Closed 0 times.

Source — Modal.rozie

rozie
<!--
  Modal.rozie

  Demonstrates:
    - <listeners> with side-effect handlers (body scroll lock, focus management)
    - .self modifier — only fires when event target IS the bound element (backdrop click)
    - $emit for parent-controlled close
    - Multiple $onMount and $onUnmount hooks colocated with the logic they serve
    - $el — root element access for vanilla-JS lib integration (focus-trap library)
    - r-if (full unmount) chosen over r-show — modals shouldn't stay in the tree

  Note: this is the kind of component where the <listeners> block + .outside-style
  modifiers replace ~30 lines of imperative addEventListener / removeEventListener
  boilerplate that has to be written four times for a four-framework component library.
-->

<rozie name="Modal">

<props>
{
  open:           { type: Boolean, default: false, model: true },
  closeOnEscape:  { type: Boolean, default: true },
  closeOnBackdrop:{ type: Boolean, default: true },
  lockBodyScroll: { type: Boolean, default: true },
  title:          { type: String,  default: '' },
}
</props>

<script>
const close = () => { $model.open = false; $emit('close') }

// Body-scroll-lock state lives outside reactive data because it tracks DOM
// rather than UI; managed entirely via lifecycle and listeners.
let savedBodyOverflow = ''

const lockScroll = () => {
  if (!$props.lockBodyScroll || !$props.open) return
  savedBodyOverflow = document.body.style.overflow
  document.body.style.overflow = 'hidden'
}

const unlockScroll = () => {
  if (!$props.lockBodyScroll) return
  document.body.style.overflow = savedBodyOverflow
}

// $watch re-fires on every `open` toggle — the cross-target primitive for
// reacting to a prop change. The $onMount/$onUnmount pair anchors the
// unmount-time restore; $onMount runs exactly once on every target (a
// guarded no-op here) and must not be relied on to re-fire.
$watch(() => $props.open, (isOpen) => {
  if (isOpen) lockScroll()
  else unlockScroll()
})
$onMount(lockScroll)
$onUnmount(unlockScroll)

// Colocated focus management — separate hook, separate concern.
$onMount(() => {
  $refs.dialogEl?.focus()
})
</script>

<listeners>
  <!--
    Backdrop click. .self ensures we only close when the click target IS the
    backdrop element itself, not a child (the dialog panel) that bubbled up.
    r-if here means conditional attach/detach, not conditional render.
  -->
  <listener :target="document" @keydown.escape="close" r-if="$props.open && $props.closeOnEscape" />
</listeners>

<template>
<div r-if="$props.open" class="modal-backdrop" ref="backdropEl" @click.self="$props.closeOnBackdrop && close()">
  <div
    ref="dialogEl"
    class="modal-dialog"
    role="dialog"
    aria-modal="true"
    :aria-label="$props.title || undefined"
    tabindex="-1"
  >
    <header r-if="$props.title || $slots.header">
      <slot name="header" :close="close">
        <h2>{{ $props.title }}</h2>
      </slot>
      <button class="close-btn" @click="close" aria-label="Close">×</button>
    </header>

    <div class="modal-body">
      <slot :close="close" />
    </div>

    <footer r-if="$slots.footer">
      <slot name="footer" :close="close" />
    </footer>
  </div>
</div>
</template>

<style>
.modal-backdrop {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex; align-items: center; justify-content: center;
  z-index: var(--rozie-modal-z, 2000);
}
.modal-dialog {
  background: white;
  border-radius: 8px;
  min-width: 20rem;
  max-width: min(90vw, 40rem);
  max-height: 90vh;
  display: flex; flex-direction: column;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  outline: none;
}
header, footer { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
header { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
header h2 { flex: 1; margin: 0; font-size: 1.1rem; }
footer { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
.modal-body { padding: 1rem; overflow: auto; }
.close-btn { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }

:root {
  --rozie-modal-z: 2000;
}
</style>

</rozie>

Compiled output

vue
<template>

<div v-if="open" class="modal-backdrop" ref="backdropElRef" @click.self="props.closeOnBackdrop && close()">
  <div ref="dialogElRef" class="modal-dialog" role="dialog" aria-modal="true" :aria-label="props.title || undefined" tabindex="-1">
    <header v-if="props.title || $slots.header">
      <slot name="header" :close="close">
        <h2>{{ props.title }}</h2>
      </slot>
      <button class="close-btn" aria-label="Close" @click="close">×</button>
    </header><div class="modal-body">
      <slot :close="close"></slot>
    </div>

    <footer v-if="$slots.footer">
      <slot name="footer" :close="close"></slot>
    </footer></div>
</div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';

const props = withDefaults(
  defineProps<{ closeOnEscape?: boolean; closeOnBackdrop?: boolean; lockBodyScroll?: boolean; title?: string }>(),
  { closeOnEscape: true, closeOnBackdrop: true, lockBodyScroll: true, title: '' }
);

const open = defineModel<boolean>('open', { default: false });

const emit = defineEmits<{
  close: [...args: any[]];
}>();

defineSlots<{
  header(props: { close: any }): any;
  default(props: { close: any }): any;
  footer(props: { close: any }): any;
}>();

const backdropElRef = ref<HTMLElement>();
const dialogElRef = ref<HTMLElement>();

const close = () => {
  open.value = false;
  emit('close');
};

// Body-scroll-lock state lives outside reactive data because it tracks DOM
// rather than UI; managed entirely via lifecycle and listeners.
// Body-scroll-lock state lives outside reactive data because it tracks DOM
// rather than UI; managed entirely via lifecycle and listeners.
let savedBodyOverflow = '';
const lockScroll = () => {
  if (!props.lockBodyScroll || !open.value) return;
  savedBodyOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
  if (!props.lockBodyScroll) return;
  document.body.style.overflow = savedBodyOverflow;
};

// $watch re-fires on every `open` toggle — the cross-target primitive for
// reacting to a prop change. The $onMount/$onUnmount pair anchors the
// unmount-time restore; $onMount runs exactly once on every target (a
// guarded no-op here) and must not be relied on to re-fire.

onMounted(lockScroll);
onBeforeUnmount(unlockScroll);
onMounted(() => {
  dialogElRef.value?.focus();
});

watch(() => open.value, (isOpen: any) => {
  if (isOpen) lockScroll();else unlockScroll();
});

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));
});
</script>

<style scoped>
.modal-backdrop {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex; align-items: center; justify-content: center;
  z-index: var(--rozie-modal-z, 2000);
}
.modal-dialog {
  background: white;
  border-radius: 8px;
  min-width: 20rem;
  max-width: min(90vw, 40rem);
  max-height: 90vh;
  display: flex; flex-direction: column;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  outline: none;
}
header, footer { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
header { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
header h2 { flex: 1; margin: 0; font-size: 1.1rem; }
footer { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
.modal-body { padding: 1rem; overflow: auto; }
.close-btn { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }
</style>

<style>
:root {
  --rozie-modal-z: 2000;
}
</style>
tsx
import { useCallback, useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Modal.css';
import './Modal.global.css';

interface HeaderCtx { close: any; }

interface ChildrenCtx { close: any; }

interface FooterCtx { close: any; }

interface ModalProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
  lockBodyScroll?: boolean;
  title?: string;
  onClose?: (...args: any[]) => void;
  renderHeader?: (ctx: HeaderCtx) => ReactNode;
  children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
  renderFooter?: (ctx: FooterCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function Modal(_props: ModalProps): JSX.Element {
  const props: Omit<ModalProps, 'closeOnEscape' | 'closeOnBackdrop' | 'lockBodyScroll' | 'title'> & { closeOnEscape: boolean; closeOnBackdrop: boolean; lockBodyScroll: boolean; title: string } = {
    ..._props,
    closeOnEscape: _props.closeOnEscape ?? true,
    closeOnBackdrop: _props.closeOnBackdrop ?? true,
    lockBodyScroll: _props.lockBodyScroll ?? true,
    title: _props.title ?? '',
  };
  const attrs: Record<string, unknown> = (() => {
    const { open, closeOnEscape, closeOnBackdrop, lockBodyScroll, title, defaultValue, onOpenChange, defaultOpen, ...rest } = _props as ModalProps & Record<string, unknown>;
    void open; void closeOnEscape; void closeOnBackdrop; void lockBodyScroll; void title; void defaultValue; void onOpenChange; void defaultOpen;
    return rest;
  })();
  const savedBodyOverflow = useRef('');
  const [open, setOpen] = useControllableState({
    value: props.open,
    defaultValue: props.defaultOpen ?? false,
    onValueChange: props.onOpenChange,
  });
  const backdropEl = useRef<HTMLDivElement | null>(null);
  const dialogEl = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);

  const { onClose: _rozieProp_onClose } = props;
    const close = useCallback(() => {
    setOpen(false);
    _rozieProp_onClose && _rozieProp_onClose();
  }, [_rozieProp_onClose, setOpen]);
  const lockScroll = useCallback(() => {
    if (!props.lockBodyScroll || !open) return;
    savedBodyOverflow.current = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
  }, [open, props.lockBodyScroll]);
  const unlockScroll = useCallback(() => {
    if (!props.lockBodyScroll) return;
    document.body.style.overflow = savedBodyOverflow.current;
  }, [props.lockBodyScroll]);

  useEffect(() => {
    lockScroll();
    return () => unlockScroll();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    dialogEl.current?.focus();
  }, []);
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const isOpen = open;
    if (isOpen) lockScroll();else unlockScroll();
  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps

  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]);

  return (
    <>
    {(open) && <div className={"modal-backdrop"} ref={backdropEl} onClick={($event) => { if ($event.target !== $event.currentTarget) return; props.closeOnBackdrop && close(); }} data-rozie-s-fc45feb2="">
      <div ref={dialogEl} className={"modal-dialog"} role="dialog" aria-modal="true" aria-label={rozieAttr(props.title || undefined)} tabIndex={-1} data-rozie-s-fc45feb2="">
        {(props.title || (props.renderHeader ?? props.slots?.['header'])) && <header data-rozie-s-fc45feb2="">
          {(props.renderHeader ?? props.slots?.['header']) ? ((props.renderHeader ?? props.slots?.['header']) as Function)({ close }) : <h2 data-rozie-s-fc45feb2="">{props.title}</h2>}
          <button className={"close-btn"} aria-label="Close" onClick={close} data-rozie-s-fc45feb2="">×</button>
        </header>}<div className={"modal-body"} data-rozie-s-fc45feb2="">
          {typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ close }) : (props.children ?? props.slots?.[''])}
        </div>

        {((props.renderFooter ?? props.slots?.['footer'])) && <footer data-rozie-s-fc45feb2="">
          {(props.renderFooter ?? props.slots?.['footer'])?.({ close })}
        </footer>}</div>
    </div>}</>
  );
}
svelte
<script lang="ts">
import { rozieAttr } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';

interface Props {
  open?: boolean;
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
  lockBodyScroll?: boolean;
  title?: string;
  header?: Snippet<[{ close: any }]>;
  children?: Snippet<[{ close: any }]>;
  footer?: Snippet<[{ close: any }]>;
  snippets?: Record<string, any>;
  onclose?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let {
  open = $bindable(false),
  closeOnEscape = true,
  closeOnBackdrop = true,
  lockBodyScroll = true,
  title = '',
  header: __headerProp,
  children: __childrenProp,
  footer: __footerProp,
  snippets,
  onclose,
  ...__rozieAttrs
}: Props = $props();

const header = $derived(__headerProp ?? snippets?.header);
const children = $derived(__childrenProp ?? snippets?.children);
const footer = $derived(__footerProp ?? snippets?.footer);

let backdropEl = $state<HTMLElement | undefined>(undefined);
let dialogEl = $state<HTMLElement | undefined>(undefined);

const close = () => {
  open = false;
  onclose?.();
};

// Body-scroll-lock state lives outside reactive data because it tracks DOM
// rather than UI; managed entirely via lifecycle and listeners.
// Body-scroll-lock state lives outside reactive data because it tracks DOM
// rather than UI; managed entirely via lifecycle and listeners.
let savedBodyOverflow = '';
const lockScroll = () => {
  if (!lockBodyScroll || !open) return;
  savedBodyOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
  if (!lockBodyScroll) return;
  document.body.style.overflow = savedBodyOverflow;
};

// $watch re-fires on every `open` toggle — the cross-target primitive for
// reacting to a prop change. The $onMount/$onUnmount pair anchors the
// unmount-time restore; $onMount runs exactly once on every target (a
// guarded no-op here) and must not be relied on to re-fire.

onMount(() => {
  lockScroll();
  return () => unlockScroll();
});
onMount(() => {
  dialogEl?.focus();
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => open)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
  if (isOpen) lockScroll();else unlockScroll();
})(__watchVal); }); });

$effect(() => {
  if (!(open && closeOnEscape)) return;
  const handler = ($event: KeyboardEvent) => {
    if ($event.key !== 'Escape') return;
    close();
  };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
});
</script>

{#if open}<div class="modal-backdrop" bind:this={backdropEl} onclick={($event) => { if ($event.target !== $event.currentTarget) return; closeOnBackdrop && close(); }} data-rozie-s-fc45feb2><div bind:this={dialogEl} class="modal-dialog" role="dialog" aria-modal="true" aria-label={rozieAttr(title || undefined)} tabindex="-1" data-rozie-s-fc45feb2>{#if title || header}<header data-rozie-s-fc45feb2>{#if header}{@render header({ close })}{:else}<h2 data-rozie-s-fc45feb2>{title}</h2>{/if}<button class="close-btn" aria-label="Close" onclick={close} data-rozie-s-fc45feb2>×</button></header>{/if}<div class="modal-body" data-rozie-s-fc45feb2>{@render children?.({ close })}</div>{#if footer}<footer data-rozie-s-fc45feb2>{#if footer}{@render footer({ close })}{/if}</footer>{/if}</div></div>{/if}

<style>
:global {
  .modal-backdrop[data-rozie-s-fc45feb2] {
    position: fixed; inset: 0;
    background: rgba(0, 0, 0, 0.4);
    display: flex; align-items: center; justify-content: center;
    z-index: var(--rozie-modal-z, 2000);
  }
  .modal-dialog[data-rozie-s-fc45feb2] {
    background: white;
    border-radius: 8px;
    min-width: 20rem;
    max-width: min(90vw, 40rem);
    max-height: 90vh;
    display: flex; flex-direction: column;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
    outline: none;
  }
  header[data-rozie-s-fc45feb2], footer[data-rozie-s-fc45feb2] { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
  header[data-rozie-s-fc45feb2] { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
  header[data-rozie-s-fc45feb2] h2[data-rozie-s-fc45feb2] { flex: 1; margin: 0; font-size: 1.1rem; }
  footer[data-rozie-s-fc45feb2] { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
  .modal-body[data-rozie-s-fc45feb2] { padding: 1rem; overflow: auto; }
  .close-btn[data-rozie-s-fc45feb2] { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }
}

:global(:root) {
--rozie-modal-z: 2000;
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

interface HeaderCtx {
  $implicit: { close: any };
  close: any;
}

interface DefaultCtx {
  $implicit: { close: any };
  close: any;
}

interface FooterCtx {
  $implicit: { close: any };
  close: any;
}

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-modal',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `

    @if (open()) {
    <div class="modal-backdrop" #backdropEl (click)="_guardedHandler0($event)">
      <div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [attr.aria-label]="rozieAttr(title() || undefined)" tabindex="-1">
        @if (title() || (headerTpl ?? templates()?.['header'])) {
    <header>
          @if ((headerTpl ?? templates()?.['header'])) {
    <ng-container *ngTemplateOutlet="(headerTpl ?? templates()?.['header']); context: { $implicit: { close: _close }, close: _close }" />
    } @else {

            <h2>{{ title() }}</h2>
          
    }
          <button class="close-btn" aria-label="Close" (click)="_close()">×</button>
        </header>
    }<div class="modal-body">
          <ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: { $implicit: { close: _close }, close: _close }" />
        </div>

        @if ((footerTpl ?? templates()?.['footer'])) {
    <footer>
          @if ((footerTpl ?? templates()?.['footer'])) {
    <ng-container *ngTemplateOutlet="(footerTpl ?? templates()?.['footer']); context: { $implicit: { close: _close }, close: _close }" />
    }
        </footer>
    }</div>
    </div>
    }
  `,
  styles: [`
    .modal-backdrop {
      position: fixed; inset: 0;
      background: rgba(0, 0, 0, 0.4);
      display: flex; align-items: center; justify-content: center;
      z-index: var(--rozie-modal-z, 2000);
    }
    .modal-dialog {
      background: white;
      border-radius: 8px;
      min-width: 20rem;
      max-width: min(90vw, 40rem);
      max-height: 90vh;
      display: flex; flex-direction: column;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
      outline: none;
    }
    header, footer { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
    header { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
    header h2 { flex: 1; margin: 0; font-size: 1.1rem; }
    footer { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
    .modal-body { padding: 1rem; overflow: auto; }
    .close-btn { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }

    ::ng-deep :root {
    --rozie-modal-z: 2000;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Modal),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Modal {
  open = model<boolean>(false);
  closeOnEscape = input<boolean>(true);
  closeOnBackdrop = input<boolean>(true);
  lockBodyScroll = input<boolean>(true);
  title = input<string>('');
  backdropEl = viewChild<ElementRef<HTMLDivElement>>('backdropEl');
  dialogEl = viewChild<ElementRef<HTMLDivElement>>('dialogEl');
  close = output<void>();
  @ContentChild('header', { read: TemplateRef }) headerTpl?: TemplateRef<HeaderCtx>;
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  @ContentChild('footer', { read: TemplateRef }) footerTpl?: TemplateRef<FooterCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  private __rozieDestroyRef = inject(DestroyRef);
  private __rozieWatchInitial_0 = true;

  constructor() {
      const renderer = inject(Renderer2);

      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(() => { const __watchVal = (() => this.open())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
      if (isOpen) this.lockScroll();else this.unlockScroll();
    })(__watchVal); }); });
  }

  ngAfterViewInit() {
    this.lockScroll();
    this.__rozieDestroyRef.onDestroy(this.unlockScroll);
    this.dialogEl()?.nativeElement?.focus();
  }

  _close = () => {
    this.open.set(false), this.__rozieCvaOnChange(false);
    this.close.emit();
  };
  savedBodyOverflow = '';
  lockScroll = () => {
    if (!this.lockBodyScroll() || !this.open()) return;
    this.savedBodyOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
  };
  unlockScroll = () => {
    if (!this.lockBodyScroll()) return;
    document.body.style.overflow = this.savedBodyOverflow;
  };

  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: Modal,
    _ctx: unknown,
  ): _ctx is HeaderCtx | DefaultCtx | FooterCtx {
    return true;
  }

  private _guardedHandler0 = ($event: any) => {
    if ($event.target !== $event.currentTarget) return;
    this.closeOnBackdrop() && this._close();
  };

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Modal;
tsx
import type { JSX } from 'solid-js';
import { Show, children, createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr } from '@rozie/runtime-solid';

__rozieInjectStyle('Modal-fc45feb2', `.modal-backdrop[data-rozie-s-fc45feb2] {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex; align-items: center; justify-content: center;
  z-index: var(--rozie-modal-z, 2000);
}
.modal-dialog[data-rozie-s-fc45feb2] {
  background: white;
  border-radius: 8px;
  min-width: 20rem;
  max-width: min(90vw, 40rem);
  max-height: 90vh;
  display: flex; flex-direction: column;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  outline: none;
}
header[data-rozie-s-fc45feb2], footer[data-rozie-s-fc45feb2] { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
header[data-rozie-s-fc45feb2] { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
header[data-rozie-s-fc45feb2] h2[data-rozie-s-fc45feb2] { flex: 1; margin: 0; font-size: 1.1rem; }
footer[data-rozie-s-fc45feb2] { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
.modal-body[data-rozie-s-fc45feb2] { padding: 1rem; overflow: auto; }
.close-btn[data-rozie-s-fc45feb2] { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }
:root {
  --rozie-modal-z: 2000;
}`);

interface HeaderSlotCtx { close: any; }

interface FooterSlotCtx { close: any; }

interface ModalProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
  lockBodyScroll?: boolean;
  title?: string;
  onClose?: (...args: unknown[]) => void;
  headerSlot?: (ctx: HeaderSlotCtx) => JSX.Element;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  footerSlot?: (ctx: FooterSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function Modal(_props: ModalProps): JSX.Element {
  const _merged = mergeProps({ closeOnEscape: true, closeOnBackdrop: true, lockBodyScroll: true, title: '' }, _props);
  const [local, attrs] = splitProps(_merged, ['open', 'closeOnEscape', 'closeOnBackdrop', 'lockBodyScroll', 'title', 'children']);
  const resolved = children(() => local.children);

  const [open, setOpen] = createControllableSignal<boolean>(_props as unknown as Record<string, unknown>, 'open', false);
  onMount(() => {
    const _cleanup = (lockScroll)() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(unlockScroll);
  });
  onMount(() => {
    dialogElRef?.focus();
  });
  createEffect(on(() => (() => open())(), (v) => untrack(() => ((isOpen: any) => {
    if (isOpen) lockScroll();else unlockScroll();
  })(v)), { defer: true }));
  let backdropElRef: HTMLElement | null = null;
  let dialogElRef: HTMLElement | null = null;

  function close() {
    setOpen(false);
    _props.onClose?.();
  }

  // Body-scroll-lock state lives outside reactive data because it tracks DOM
  // rather than UI; managed entirely via lifecycle and listeners.
  let savedBodyOverflow = '';
  function lockScroll() {
    if (!local.lockBodyScroll || !open()) return;
    savedBodyOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
  }
  function unlockScroll() {
    if (!local.lockBodyScroll) return;
    document.body.style.overflow = savedBodyOverflow;
  }

  // $watch re-fires on every `open` toggle — the cross-target primitive for
  // reacting to a prop change. The $onMount/$onUnmount pair anchors the
  // unmount-time restore; $onMount runs exactly once on every target (a
  // guarded no-op here) and must not be relied on to re-fire.

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

  return (
    <>
    {<Show when={open()}><div class={"modal-backdrop"} ref={(el) => { backdropElRef = el as HTMLElement; }} onClick={($event) => { if ($event.target !== $event.currentTarget) return; local.closeOnBackdrop && close(); }} data-rozie-s-fc45feb2="">
      <div ref={(el) => { dialogElRef = el as HTMLElement; }} class={"modal-dialog"} role="dialog" aria-modal="true" aria-label={rozieAttr(local.title || undefined)} tabIndex={-1} data-rozie-s-fc45feb2="">
        {<Show when={local.title || (_props.headerSlot ?? _props.slots?.['header'])}><header data-rozie-s-fc45feb2="">
          {(_props.headerSlot ?? _props.slots?.['header'])?.({ close }) ?? <h2 data-rozie-s-fc45feb2="">{local.title}</h2>}
          <button aria-label="Close" class={"close-btn"} onClick={close} data-rozie-s-fc45feb2="">×</button>
        </header></Show>}<div class={"modal-body"} data-rozie-s-fc45feb2="">
          {typeof local.children === 'function' ? (local.children as (s: any) => any)({ close }) : resolved()}
        </div>

        {<Show when={(_props.footerSlot ?? _props.slots?.['footer'])}><footer data-rozie-s-fc45feb2="">
          {(_props.footerSlot ?? _props.slots?.['footer'])?.({ close })}
        </footer></Show>}</div>
    </div></Show>}</>
  );
}
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 { createLitControllableProperty, injectGlobalStyles, rozieAttr } from '@rozie/runtime-lit';

@customElement('rozie-modal')
export default class Modal extends SignalWatcher(LitElement) {
  static styles = css`
.modal-backdrop[data-rozie-s-fc45feb2] {
  position: fixed; inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex; align-items: center; justify-content: center;
  z-index: var(--rozie-modal-z, 2000);
}
.modal-dialog[data-rozie-s-fc45feb2] {
  background: white;
  border-radius: 8px;
  min-width: 20rem;
  max-width: min(90vw, 40rem);
  max-height: 90vh;
  display: flex; flex-direction: column;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  outline: none;
}
header[data-rozie-s-fc45feb2], footer[data-rozie-s-fc45feb2] { padding: 1rem; display: flex; align-items: center; gap: 0.5rem; }
header[data-rozie-s-fc45feb2] { border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
header[data-rozie-s-fc45feb2] h2[data-rozie-s-fc45feb2] { flex: 1; margin: 0; font-size: 1.1rem; }
footer[data-rozie-s-fc45feb2] { border-top: 1px solid rgba(0, 0, 0, 0.08); justify-content: flex-end; }
.modal-body[data-rozie-s-fc45feb2] { padding: 1rem; overflow: auto; }
.close-btn[data-rozie-s-fc45feb2] { background: none; border: none; cursor: pointer; font-size: 1.5rem; line-height: 1; }
`;

  @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 }) closeOnEscape: boolean = true;
  @property({ type: Boolean, reflect: true }) closeOnBackdrop: boolean = true;
  @property({ type: Boolean, reflect: true }) lockBodyScroll: boolean = true;
  @property({ type: String, reflect: true }) title: string = '';
  @query('[data-rozie-ref="backdropEl"]') private _refBackdropEl!: HTMLElement;
  @query('[data-rozie-ref="dialogEl"]') private _refDialogEl!: HTMLElement;
private __rozieWatchInitial_0 = true;

  @state() private _hasSlotHeader = false;
  @queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
  @property({ attribute: false }) header?: (scope: { close: unknown }) => unknown;
  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
  @property({ attribute: false }) __rozieDefaultSlot__?: (scope: { close: unknown }) => unknown;
  @state() private _hasSlotFooter = false;
  @queryAssignedElements({ slot: 'footer', flatten: true }) private _slotFooterElements!: Element[];
  @property({ attribute: false }) footer?: (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 _lh0 = ($event: KeyboardEvent) => { if (!(this.open && this.closeOnEscape)) return; if ($event.key !== 'Escape') return; ((this.close) as (...args: any[]) => any)($event); };
    document.addEventListener('keydown', _lh0, undefined);
    this._disconnectCleanups.push(() => document.removeEventListener('keydown', _lh0, undefined));

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="header"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotHeader = this._slotHeaderElements.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();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="footer"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotFooter = this._slotFooterElements.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._hasSlotHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'header');
    this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    this._hasSlotFooter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'footer');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();

    this._disconnectCleanups.push((this.unlockScroll));

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.open)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
      if (isOpen) this.lockScroll();else this.unlockScroll();
    })(__watchVal); }); }));

    this.lockScroll();

    this._refDialogEl?.focus();
  }

  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`
${this.open ? html`<div class="modal-backdrop" @click=${($event: MouseEvent) => { if ($event.target !== $event.currentTarget) return; this.closeOnBackdrop && this.close(); }} data-rozie-ref="backdropEl" data-rozie-s-fc45feb2>
  <div class="modal-dialog" role="dialog" aria-modal="true" aria-label=${rozieAttr(this.title || undefined)} tabindex="-1" data-rozie-ref="dialogEl" data-rozie-s-fc45feb2>
    ${this.title || this._hasSlotHeader || this.header !== undefined ? html`<header data-rozie-s-fc45feb2>
      ${this.header !== undefined ? this.header({close: this.close}) : html`<slot name="header" @rozie-header-close=${($event: CustomEvent) => ((this.close) as (...args: any[]) => any)($event.detail)}>
        <h2 data-rozie-s-fc45feb2>${this.title}</h2>
      </slot>`}
      <button class="close-btn" aria-label="Close" @click=${this.close} data-rozie-s-fc45feb2>×</button>
    </header>` : nothing}<div class="modal-body" data-rozie-s-fc45feb2>
      ${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>

    ${this._hasSlotFooter || this.footer !== undefined ? html`<footer data-rozie-s-fc45feb2>
      ${this.footer !== undefined ? this.footer({close: this.close}) : html`<slot name="footer" @rozie-footer-close=${($event: CustomEvent) => ((this.close) as (...args: any[]) => any)($event.detail)}></slot>`}
    </footer>` : nothing}</div>
</div>` : nothing}`;
  }

  close = () => {
  this._openControllable.write(false);
  this.dispatchEvent(new CustomEvent("close", {
    detail: undefined,
    bubbles: true,
    composed: true
  }));
};

  savedBodyOverflow = '';

  lockScroll = () => {
  if (!this.lockBodyScroll || !this.open) return;
  this.savedBodyOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
};

  unlockScroll = () => {
  if (!this.lockBodyScroll) return;
  document.body.style.overflow = this.savedBodyOverflow;
};

  get open(): boolean { return this._openControllable.read(); }
  set open(v: boolean) { this._openControllable.notifyPropertyWrite(v); }
}

injectGlobalStyles('rozie-modal-global', `
:root {
  --rozie-modal-z: 2000;
}
`);

Pre-v1.0 — internal monorepo.