Appearance
Modal
The heaviest example in this set. Demonstrates the <listeners> block, .self modifier on a backdrop click, multiple colocated $onMount / $onUnmount hooks, $emit for parent-controlled close, named slots with scoped params, r-if (full unmount, not just hidden), and the <components> block (Modal embeds Counter in its body).
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's body has a <Counter /> inside it — that nested component is examples/Counter.rozie, resolved by the unplugin via Modal's <components> block.
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>
<components>
{
Counter: './Counter.rozie',
}
</components>
<script>
const close = () => { $props.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) return
savedBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
const unlockScroll = () => {
if (!$props.lockBodyScroll) return
document.body.style.overflow = savedBodyOverflow
}
// Colocated lifecycle pair — runs in source order alongside other hooks.
$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.
// Bound to a template ref via the implicit `target:` form — see notes below.
"document:keydown.escape": {
when: "$props.open && $props.closeOnEscape",
handler: close,
},
}
</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" />
<Counter />
</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>Vue 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>
<Counter></Counter>
</div>
<footer v-if="$slots.footer">
<slot name="footer" :close="close"></slot>
</footer></div>
</div>
</template>
<script setup lang="ts">
import Counter from './Counter.vue';
import { onBeforeUnmount, onMounted, ref, 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) return;
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
if (!props.lockBodyScroll) return;
document.body.style.overflow = savedBodyOverflow;
};
// Colocated lifecycle pair — runs in source order alongside other hooks.
onMounted(lockScroll);
onBeforeUnmount(unlockScroll);
onMounted(() => {
dialogElRef.value?.focus();
});
watchEffect((onCleanup) => {
if (!(open.value && props.closeOnEscape)) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', handler);
onCleanup(() => document.removeEventListener('keydown', handler));
});
</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>React output
tsx
import { useCallback, useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { useControllableState } from '@rozie/runtime-react';
import styles from './Modal.module.css';
import './Modal.global.css';
import Counter from './Counter';
interface HeaderCtx { close: any; }
interface ChildrenCtx { close: any; }
interface FooterCtx { close: any; }
interface ModalProps {
open?: boolean;
defaultValue?: boolean;
onOpenChange?: (open: boolean) => void;
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
lockBodyScroll?: boolean;
title?: string;
onClose?: (...args: unknown[]) => void;
renderHeader?: (ctx: HeaderCtx) => ReactNode;
children?: (ctx: ChildrenCtx) => ReactNode;
renderFooter?: (ctx: FooterCtx) => ReactNode;
}
export default function Modal(_props: ModalProps): JSX.Element {
const props: ModalProps = {
..._props,
closeOnEscape: _props.closeOnEscape ?? true,
closeOnBackdrop: _props.closeOnBackdrop ?? true,
lockBodyScroll: _props.lockBodyScroll ?? true,
title: _props.title ?? '',
};
const savedBodyOverflow = useRef('');
const [open, setOpen] = useControllableState({
value: props.open,
defaultValue: props.defaultValue ?? false,
onValueChange: props.onOpenChange,
});
const backdropEl = useRef<HTMLDivElement | null>(null);
const dialogEl = useRef<HTMLDivElement | null>(null);
const { onClose: _rozieProp_onClose } = props;
const close = useCallback(() => {
setOpen(false);
_rozieProp_onClose && _rozieProp_onClose();
}, [_rozieProp_onClose, setOpen]);
const lockScroll = useCallback(() => {
if (!props.lockBodyScroll) return;
savedBodyOverflow.current = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}, [props.lockBodyScroll]);
const unlockScroll = useCallback(() => {
if (!props.lockBodyScroll) return;
document.body.style.overflow = savedBodyOverflow.current;
}, [props.lockBodyScroll]);
useEffect(() => {
lockScroll();
return () => unlockScroll();
}, [lockScroll, unlockScroll]);
useEffect(() => {
dialogEl.current?.focus();
}, []);
useEffect(() => {
if (!(open && props.closeOnEscape)) return;
const _rozieHandler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close(e);
};
document.addEventListener('keydown', _rozieHandler);
return () => document.removeEventListener('keydown', _rozieHandler);
}, [close, open, props.closeOnEscape]);
return (
<>
{(open) && <div className={styles["modal-backdrop"]} ref={backdropEl} onClick={(e) => { if (e.target !== e.currentTarget) return; props.closeOnBackdrop && close(); }}>
<div ref={dialogEl} className={styles["modal-dialog"]} role="dialog" aria-modal="true" aria-label={props.title || undefined} tabIndex={-1}>
{(props.title || props.renderHeader) && <header>
{props.renderHeader ? props.renderHeader({ close }) : <h2>{props.title}</h2>}
<button className={styles["close-btn"]} aria-label="Close" onClick={close}>×</button>
</header>}<div className={styles["modal-body"]}>
{props.children?.({ close })}
<Counter />
</div>
{(props.renderFooter) && <footer>
{props.renderFooter?.({ close })}
</footer>}</div>
</div>}</>
);
}Svelte output
svelte
<script lang="ts">
import Counter from './Counter.svelte';
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
lockBodyScroll?: boolean;
title?: string;
header?: Snippet<[any]>;
children?: Snippet<[any]>;
footer?: Snippet<[any]>;
onclose?: (...args: unknown[]) => void;
}
let {
open = $bindable(false),
closeOnEscape = true,
closeOnBackdrop = true,
lockBodyScroll = true,
title = '',
header,
children,
footer,
onclose,
}: Props = $props();
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) return;
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
if (!lockBodyScroll) return;
document.body.style.overflow = savedBodyOverflow;
};
// Colocated lifecycle pair — runs in source order alongside other hooks.
$effect(() => {
lockScroll();
return () => unlockScroll();
});
$effect(() => {
dialogEl?.focus();
});
$effect(() => {
if (!(open && closeOnEscape)) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
</script>
{#if open}<div class="modal-backdrop" bind:this={backdropEl} onclick={(e) => { if (e.target !== e.currentTarget) return; closeOnBackdrop && close(); }}>
<div bind:this={dialogEl} class="modal-dialog" role="dialog" aria-modal="true" aria-label={title || undefined} tabindex="-1">
{#if title || header}<header>
{#if header}{@render header(close)}{:else}
<h2>{title}</h2>
{/if}
<button class="close-btn" aria-label="Close" onclick={close}>×</button>
</header>{/if}<div class="modal-body">
{@render children?.(close)}
<Counter></Counter>
</div>
{#if footer}<footer>
{#if footer}{@render footer(close)}{/if}
</footer>{/if}</div>
</div>{/if}
<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; }
:global(:root) {
--rozie-modal-z: 2000;
}
</style>Angular output
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, effect, inject, input, model, output, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { Counter } from './Counter';
interface HeaderCtx {
$implicit: { close: any };
close: any;
}
interface DefaultCtx {
$implicit: { close: any };
close: any;
}
interface FooterCtx {
$implicit: { close: any };
close: any;
}
@Component({
selector: 'rozie-modal',
standalone: true,
imports: [NgTemplateOutlet, Counter],
template: `
@if (open()) {
<div class="modal-backdrop" #backdropEl (click)="_guardedHandler0($event)">
<div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [aria-label]="title() || undefined" tabindex="-1">
@if (title() || headerTpl) {
<header>
@if (headerTpl) {
<ng-container *ngTemplateOutlet="headerTpl; context: { $implicit: { close: _close }, close: _close }" />
} @else {
<h2>{{ title() }}</h2>
}
<button class="close-btn" aria-label="Close" (click)="_close($event)">×</button>
</header>
}<div class="modal-body">
<ng-container *ngTemplateOutlet="defaultTpl; context: { $implicit: { close: _close }, close: _close }" />
<rozie-counter></rozie-counter>
</div>
@if (footerTpl) {
<footer>
@if (footerTpl) {
<ng-container *ngTemplateOutlet="footerTpl; 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;
}
`],
})
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>;
constructor() {
const renderer = inject(Renderer2);
effect((onCleanup) => {
if (!(this.open() && this.closeOnEscape())) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
this._close();
};
const unlisten = renderer.listen('document', 'keydown', handler);
onCleanup(unlisten);
});
this.lockScroll();
inject(DestroyRef).onDestroy(this.unlockScroll);
this.dialogEl()?.nativeElement?.focus();
}
_close = () => {
this.open.set(false);
this.close.emit();
};
savedBodyOverflow = '';
lockScroll = () => {
if (!this.lockBodyScroll()) return;
this.savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
unlockScroll = () => {
if (!this.lockBodyScroll()) return;
document.body.style.overflow = this.savedBodyOverflow;
};
static ngTemplateContextGuard(
_dir: Modal,
_ctx: unknown,
): _ctx is HeaderCtx | DefaultCtx | FooterCtx {
return true;
}
private _guardedHandler0 = (e: any) => {
if (e.target !== e.currentTarget) return;
this.closeOnBackdrop() && this._close();
};
}
export default Modal;Solid output
tsx
import type { JSX } from 'solid-js';
import { Show, children, createEffect, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { createControllableSignal } from '@rozie/runtime-solid';
import Counter from './Counter';
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;
}
export default function Modal(_props: ModalProps): JSX.Element {
const _merged = mergeProps({ closeOnEscape: true, closeOnBackdrop: true, lockBodyScroll: true, title: '' }, _props);
const [local, rest] = splitProps(_merged, ['open', 'closeOnEscape', 'closeOnBackdrop', 'lockBodyScroll', 'title', 'children']);
const resolved = children(() => local.children);
const [open, setOpen] = createControllableSignal(_props as Record<string, unknown>, 'open', false);
onMount(() => {
const _cleanup = (lockScroll)() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(unlockScroll);
});
onMount(() => {
dialogElRef?.focus();
});
let backdropElRef: HTMLElement | null = null;
let dialogElRef: HTMLElement | null = null;
const 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 = '';
const lockScroll = () => {
if (!local.lockBodyScroll) return;
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
};
const unlockScroll = () => {
if (!local.lockBodyScroll) return;
document.body.style.overflow = savedBodyOverflow;
};
// Colocated lifecycle pair — runs in source order alongside other hooks.
createEffect(() => {
if (!(open() && local.closeOnEscape)) return;
const _rozieHandler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
document.addEventListener('keydown', _rozieHandler);
onCleanup(() => document.removeEventListener('keydown', _rozieHandler));
});
return (
<>
<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; }`}</style>
<style>{`:root {
--rozie-modal-z: 2000;
}`}</style>
<>
{<Show when={open()}><div class={"modal-backdrop"} ref={(el) => { backdropElRef = el as HTMLElement; }} onClick={(e) => { if (e.target !== e.currentTarget) return; local.closeOnBackdrop && close(); }}>
<div ref={(el) => { dialogElRef = el as HTMLElement; }} class={"modal-dialog"} role="dialog" aria-modal="true" aria-label={local.title || undefined} tabIndex={-1}>
{<Show when={local.title || _props.headerSlot}><header>
{_props.headerSlot ? _props.headerSlot({ close }) : <h2>{local.title}</h2>}
<button aria-label="Close" class={"close-btn"} onClick={close}>×</button>
</header></Show>}<div class={"modal-body"}>
{resolved()}
<Counter />
</div>
{<Show when={_props.footerSlot}><footer>
{_props.footerSlot?.({ close })}
</footer></Show>}</div>
</div></Show>}</>
</>
);
}Lit output
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { createLitControllableProperty, injectGlobalStyles } from '@rozie/runtime-lit';
import './Counter.rozie';
interface RozieHeaderSlotCtx {
close: unknown;
}
interface RozieDefaultSlotCtx {
close: unknown;
}
interface RozieFooterSlotCtx {
close: unknown;
}
@customElement('rozie-modal')
export default class Modal extends SignalWatcher(LitElement) {
static styles = css`
.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; }
`;
@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;
@state() private _hasSlotHeader = false;
@queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@state() private _hasSlotFooter = false;
@queryAssignedElements({ slot: 'footer', flatten: true }) private _slotFooterElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
private _armListeners(): void {
const _lh0 = (e: Event) => { if (!(this.open && this.closeOnEscape)) return; if ((e as KeyboardEvent).key !== 'Escape') return; (this.close)(e); };
document.addEventListener('keydown', _lh0, undefined);
this._disconnectCleanups.push(() => document.removeEventListener('keydown', _lh0, undefined));
this.addEventListener('rozie-header-close', (e) => { (this.close)((e as CustomEvent).detail); });
this.addEventListener('rozie-default-close', (e) => { (this.close)((e as CustomEvent).detail); });
this.addEventListener('rozie-footer-close', (e) => { (this.close)((e as CustomEvent).detail); });
{
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 {
super.connectedCallback();
if (this.hasUpdated) this._armListeners();
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push((this.unlockScroll));
this.lockScroll();
this._refDialogEl?.focus();
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'open') this._openControllable.notifyAttributeChange(value !== null);
}
render() {
return html`
${this.open ? html`<div class="modal-backdrop" @click=${(e: Event) => { if (e.target !== e.currentTarget) return; this.closeOnBackdrop && this.close(); }} data-rozie-ref="backdropEl">
<div class="modal-dialog" role="dialog" aria-modal="true" aria-label=${this.title || undefined} tabindex="-1" data-rozie-ref="dialogEl">
${this.title || this._hasSlotHeader ? html`<header>
<slot name="header">
<h2>${this.title}</h2>
</slot>
<button class="close-btn" aria-label="Close" @click=${this.close}>×</button>
</header>` : nothing}<div class="modal-body">
<slot></slot>
<rozie-counter></rozie-counter>
</div>
${this._hasSlotFooter ? html`<footer>
<slot name="footer"></slot>
</footer>` : nothing}</div>
</div>` : nothing}`;
}
close = () => {
this.open = false;
this.dispatchEvent(new CustomEvent("close", {
detail: undefined,
bubbles: true,
composed: true
}));
};
savedBodyOverflow = '';
lockScroll = () => {
if (!this.lockBodyScroll) 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.write(v); }
}
injectGlobalStyles('rozie-modal-global', `
:root {
--rozie-modal-z: 2000;
}
`);