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