@rcarls/rc-dialog
v0.5.0
Published
Draggable, resizable wrapper for a native <dialog>.
Readme
@rcarls/rc-dialog
Draggable, resizable wrapper for a native <dialog>, following the WAI-ARIA Dialog Modal pattern.
Docs: https://richardcarls.github.io/rc-webcomponents/components/rc-dialog.
Installation
npm install @rcarls/rc-dialogImport
import '@rcarls/rc-dialog'; // side-effect: registers <rc-dialog>
import { RCDialog } from '@rcarls/rc-dialog'; // named class exportBasic usage
Place a <dialog> element directly inside <rc-dialog>. The inner <dialog> must have
aria-labelledby or aria-label to satisfy the
WAI-ARIA Dialog pattern.
<rc-dialog id="my-dialog">
<dialog aria-labelledby="dlg-title">
<h2 id="dlg-title">Hello</h2>
<p>Dialog content.</p>
<button onclick="document.querySelector('#my-dialog').close()">Close</button>
</dialog>
</rc-dialog>
<button onclick="document.querySelector('#my-dialog').showModal()">Open</button>API
Properties / attributes
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
| open | open | boolean | None | Controlled open state. Setting to true/false opens/closes the dialog silently (no rc-dialog-toggle event). Reads the inner <dialog>.open value. |
| defaultOpen | default-open | boolean | false | Uncontrolled initial open state. The component takes ownership after initialization. |
| modal | None | boolean | true | Whether controlled open / defaultOpen opens as modal (showModal) or non-modal (show). No effect on direct showModal() / show() calls. JS property only; no attribute. |
| movable | movable | boolean | false | Enable drag-to-move. Named movable (not draggable) to avoid colliding with the HTML draggable attribute. |
| moveHandle | move-handle | string | '' | CSS selector for the drag handle within the inner <dialog> (for example, '.titlebar'). Defaults to the whole dialog. |
| moveBounds | move-bounds | 'viewport' \| 'parent' | 'viewport' | Constrains drag within the viewport or the nearest positioned ancestor. |
| moveStep | move-step | number | 4 | Keyboard arrow-key step in px when moving. Shift multiplies by 10×. |
| resize | resize | 'none' \| 'both' \| 'horizontal' \| 'vertical' | 'none' | Enables edge/corner resizing, mirroring CSS resize semantics. |
| resizeOrigin | resize-origin | '' \| 'top' \| 'right' \| 'bottom' \| 'left' \| 'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right' | '' | Constrains fallback edge detection or supplies the default origin for explicit handles. Empty keeps free edge detection. |
| resizeHandle | resize-handle | string | '' | CSS selector for explicit resize handles within the inner <dialog>. Matching handles receive pointer and keyboard resize behavior. |
| resizeThreshold | resize-threshold | number | 8 | Edge hit-test band in px (straddles the border, half inside and half outside). |
| resizeStep | resize-step | number | 4 | Keyboard arrow-key step in px when resizing. Shift multiplies by 10×. |
| closedBy | closed-by | 'any' \| 'closerequest' \| 'none' \| '' | '' | Proxied to the inner <dialog closedby="..."> attribute (Chrome 134+, Safari 18.4+, Firefox 139+). 'any' = Escape or backdrop click; 'closerequest' = Escape only; 'none' = programmatic only. |
| lightDismiss | light-dismiss | boolean | false | JS fallback for backdrop-click dismissal. Detects clicks whose target is the <dialog> element itself and calls requestClose(). Works in all browsers alongside or instead of closed-by. |
Methods
showModal(): void // Opens as modal (traps focus, shows backdrop).
show(): void // Opens as non-modal.
close(returnValue?): void // Closes immediately; sets returnValue.
requestClose(returnValue?): void
// Requests close: fires rc-dialog-request-close first. If not prevented,
// proceeds to close. Falls back to a synthesized cancel event on older
// browsers that lack native HTMLDialogElement.requestClose().Read-only getters
open: boolean // Whether the inner <dialog> is currently open.
returnValue: string // The return value set when the dialog last closed.Events
| Event | Cancelable | Detail | Description |
|---|---|---|---|
| rc-dialog-open | No | None | Fired when the dialog opens via showModal() or show(). |
| rc-dialog-toggle | No | { open: boolean, returnValue: string } | Fired when user or native interaction changes the open state. Not fired on silent host writes (open property). |
| rc-dialog-request-close | Yes | { returnValue: string } | Fired before close (Escape, backdrop click, or requestClose()). Call preventDefault() to block. |
| rc-dialog-cancel | No | None | Fired after rc-dialog-request-close when the close was not prevented. Backward-compatible alias. |
| rc-dialog-close | No | { returnValue: string } | Fired after the dialog has closed. |
Theming
Set --rc-dialog-scrim on <rc-dialog> or a theme scope to customize the modal
backdrop color. Theme packages consume this token in their ::backdrop styles.
Examples
Movable dialog with a drag handle
<rc-dialog id="dlg" movable move-handle=".titlebar">
<dialog aria-labelledby="dlg-title">
<div class="titlebar">
<span id="dlg-title">Settings</span>
<button onclick="document.querySelector('#dlg').close()">✕</button>
</div>
<div class="body">…</div>
</dialog>
</rc-dialog>Drag the .titlebar to reposition. Focus the titlebar and use Arrow keys to move it
(Shift = 10× step).
Movable + resizable
<rc-dialog id="dlg" movable move-handle=".titlebar" resize="both">
<dialog aria-labelledby="dlg-title" style="min-width: 20rem; min-height: 10rem;">
…
</dialog>
</rc-dialog>All eight resize handles are active (top, right, bottom, left, and the four corners).
Opposite-edge handles anchor the far side and move left or top, mirroring OS-window resize
semantics. A small keyboard-accessible resize button is injected at the bottom-right corner.
Explicit resize handle
<rc-dialog id="sheet-like" resize="vertical" resize-origin="top" resize-handle=".resize-handle">
<dialog aria-labelledby="sheet-title">
<button
type="button"
class="resize-handle"
data-rc-dialog-resize-axis="y"
data-rc-dialog-resize-origin="top"
aria-label="Resize"
></button>
<h2 id="sheet-title">Filters</h2>
</dialog>
</rc-dialog>Use data-rc-dialog-resize-axis="x|y|both" and
data-rc-dialog-resize-origin="top|right|bottom|left|top-left|..."
on individual handles to override the host resize direction or origin.
Minimal / no-header dialog
No titlebar or footer structure is required. Any layout is valid.
<rc-dialog id="dlg">
<dialog aria-labelledby="dlg-title" aria-describedby="dlg-desc"
style="border-radius: 12px; padding: 2rem; position: relative;">
<button style="position:absolute;top:.5rem;right:.5rem;"
onclick="document.querySelector('#dlg').close()"
aria-label="Close">✕</button>
<h3 id="dlg-title">Quick note</h3>
<p id="dlg-desc">Content here.</p>
</dialog>
</rc-dialog>Alert / confirm dialog
Use role="alertdialog" with aria-describedby pointing to the message text. Assistive
technology treats this with higher urgency.
<rc-dialog id="confirm">
<dialog role="alertdialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-msg">
<div class="titlebar"><span id="confirm-title">Delete item?</span></div>
<p id="confirm-msg">This action cannot be undone.</p>
<div class="footer">
<button onclick="document.querySelector('#confirm').close('cancel')">Cancel</button>
<button onclick="document.querySelector('#confirm').close('delete')">Delete</button>
</div>
</dialog>
</rc-dialog>Native form integration
<form method="dialog"> submits to the dialog: the submit button's value becomes
returnValue, and the dialog closes without JavaScript.
<rc-dialog id="dlg">
<dialog aria-labelledby="dlg-title">
<div class="titlebar">
<span id="dlg-title">New contact</span>
<button formmethod="dialog" form="contact-form" value="cancel">✕</button>
</div>
<form id="contact-form" method="dialog">
<label>Name <input name="name" type="text" /></label>
<label>Email <input name="email" type="email" /></label>
</form>
<div class="footer">
<button formmethod="dialog" form="contact-form" value="cancel">Cancel</button>
<button type="submit" form="contact-form" value="save">Save</button>
</div>
</dialog>
</rc-dialog>Cancelable close guard (unsaved changes)
<rc-dialog id="dlg" movable move-handle=".titlebar">
<dialog aria-labelledby="dlg-title">
<div class="titlebar">
<span id="dlg-title">Edit note</span>
<!-- requestClose() fires rc-dialog-request-close; close() bypasses it -->
<button onclick="document.querySelector('#dlg').requestClose('cancel')">✕</button>
</div>
<textarea id="note" rows="5"></textarea>
<p id="warning" hidden role="alert">Unsaved changes; save or discard first.</p>
<div class="footer">
<button onclick="note.value=''; dlg.close('discard')">Discard</button>
<button onclick="dlg.close('save')">Save</button>
</div>
</dialog>
</rc-dialog>
<script>
document.querySelector('#dlg').addEventListener('rc-dialog-request-close', (e) => {
if (document.querySelector('#note').value.trim()) {
e.preventDefault();
document.querySelector('#warning').hidden = false;
}
});
</script>Light-dismiss (click backdrop to close)
<!-- Native (Chrome 134+ / Safari 18.4+ / Firefox 139+) + JS fallback -->
<rc-dialog id="dlg" closed-by="any" light-dismiss>
<dialog aria-labelledby="dlg-title" aria-describedby="dlg-body">
<div class="header"><span id="dlg-title">Info</span></div>
<div id="dlg-body">Click the backdrop or press Escape to dismiss.</div>
</dialog>
</rc-dialog>closed-by="any" delegates to the browser's native light-dismiss on supporting browsers.
light-dismiss adds a JS click-on-backdrop fallback that works everywhere and routes through
requestClose(), so close guards still apply.
Accessibility
<rc-dialog> delegates entirely to the native <dialog> element:
- Focus trapping: Built into
showModal()(Tab/Shift+Tab stays inside the dialog). - Focus restoration:
rc-dialogcaptures the focused element before opening and restores focus to it on close. If the opener was removed from the DOM while the dialog was open, focus falls back todocument.body. - Escape to close: Native behavior routes through
rc-dialog-request-closeso guards still apply. aria-modal:showModal()impliesaria-modal="true"without an explicit attribute.
Required: The inner <dialog> must have aria-labelledby or aria-label. In development
mode the component logs a console warning if either is absent.
Alert dialogs: When role="alertdialog" is set, add aria-describedby pointing to the
message text. A dev-mode warning fires if it is missing.
CSS layout for scrollable dialogs
When the dialog needs scrollable body content (especially when resizable), use flex layout so the body grows and the header/footer stay fixed:
dialog[open] {
display: flex;
flex-direction: column;
overflow: hidden; /* dialog box doesn't scroll; body does */
}
.dlg-header,
.dlg-footer {
flex-shrink: 0;
}
.dlg-body {
flex: 1;
overflow: auto;
min-height: 0; /* lets flex child shrink below content height */
}Note:
display: flexmust be scoped todialog[open], notdialog. Author styles take precedence over the UAdialog:not([open]) { display: none }rule. Scoping to[open]prevents all dialogs from being visible on page load.
Browser support
| Feature | Requirement |
|---|---|
| Core (<dialog> delegation, events) | Chrome 37+, Firefox 98+, Safari 15.4+ |
| Drag / resize | Any browser supporting Pointer Events |
| closed-by attribute proxy | Chrome 134+, Safari 18.4+, Firefox 139+ |
| requestClose() native method | Chrome 134+, Safari 18.4+, Firefox 139+ (fallback active on older browsers) |
| light-dismiss JS fallback | All browsers |
