Basic Modal
A titled dialog with a focus trap, Escape to close and focus returned to the trigger.
6 个框架中级
A destructive confirmation that opens with focus on Cancel, not on Delete.
<!--
A confirm is an alertdialog, not a dialog: the difference tells a screen
reader this interrupted the user with something consequential rather than
merely opening a panel. aria-describedby points at the sentence that says what
will be destroyed, so the dialog announces its name AND its stakes.
Note which button gets initial focus. The trap opens on Cancel - the SAFE
action - because a modal that steals focus and lands it on "Delete" turns a
reflexive Enter into data loss. Autofocus goes to the way out, never the
irreversible thing. Red is not a safeguard; it means nothing to a screen
reader and little to a red-blind user. Focus order is the safeguard.
-->
<button class="cmodal-trigger" type="button" data-cmodal-open>Delete workspace</button>
<div class="cmodal" id="confirm-modal" hidden>
<div class="cmodal__backdrop" data-cmodal-close></div>
<div
class="cmodal__panel"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
>
<div class="cmodal__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
</div>
<h2 class="cmodal__title" id="confirm-modal-title">Delete workspace?</h2>
<p class="cmodal__desc" id="confirm-modal-desc">
This deletes 428 projects and removes 12 members. It cannot be undone.
</p>
<div class="cmodal__actions">
<button class="cmodal__btn" type="button" data-cmodal-cancel data-cmodal-close>Cancel</button>
<button class="cmodal__btn cmodal__btn--danger" type="button" data-cmodal-confirm>Delete</button>
</div>
</div>
</div>
<script>
(function () {
var FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var root = document.getElementById('confirm-modal');
var trigger = document.querySelector('[data-cmodal-open]');
var panel = root.querySelector('.cmodal__panel');
var cancel = root.querySelector('[data-cmodal-cancel]');
var confirm = root.querySelector('[data-cmodal-confirm]');
var restore = null;
function focusables() {
return Array.prototype.slice.call(panel.querySelectorAll(FOCUSABLE));
}
function open() {
restore = document.activeElement;
root.hidden = false;
document.body.style.overflow = 'hidden';
// Explicitly Cancel - NOT focusables()[0], which would drift to whatever
// markup happens to come first the next time this panel is edited.
cancel.focus();
}
function close() {
root.hidden = true;
document.body.style.overflow = '';
if (restore && typeof restore.focus === 'function') restore.focus();
}
trigger.addEventListener('click', open);
Array.prototype.forEach.call(root.querySelectorAll('[data-cmodal-close]'), function (el) {
el.addEventListener('click', close);
});
confirm.addEventListener('click', function () {
// Do the destructive work here, then close.
close();
});
root.addEventListener('keydown', function (event) {
// Escape is a cancel, never a confirm. An overlay's dismiss gesture must
// always resolve to the harmless outcome.
if (event.key === 'Escape') {
close();
return;
}
if (event.key !== 'Tab') return;
var items = focusables();
if (!items.length) return;
var first = items[0];
var last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
})();
</script>| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
open必填 | boolean | - | 组件当前是否显示。 |
title必填 | string | - | 卡片的标题文本。 |
message必填 | string | - | 通知的正文文本。 |
ctaLabel | string | 'Delete' | 行动号召按钮的文案。 |
dismissLabel | string | 'Cancel' | 关闭按钮的无障碍名称。 |
onConfirm必填 | () => void | - | 破坏性操作被确认后触发。 |
onDismiss必填 | () => void | - | 用户关闭通知时触发。 |
Two decisions carry this component. It is `role="alertdialog"`, not `dialog` - that is what says "this interrupted you with something consequential" - and `aria-describedby` points at the sentence naming what will be destroyed, so it announces its stakes and not just its title. Then: initial focus goes to Cancel, by ref, explicitly. Not `focusables()[0]`, which drifts the day someone reorders the markup. A dialog that steals focus onto "Delete" turns a reflexive Enter into data loss. Red is not the safeguard - it is invisible to a red-blind user and means nothing to a screen reader; focus order and the confirm step are the safeguard. Escape always resolves to Cancel.