目录
您知道JavaScript对话框是如何用于警告、确认和提示用户操作的吗?假设您想用新的HTML对话框元素替换JavaScript对话框。
让我解释。
我最近参与了一个项目,其中有很多API调用和通过JavaScript对话框收集的用户反馈。在等待其他开发人员编写<Modal />组件代码时,我使用了alert(),confirm()和prompt()在我的代码中。例如:
const deleteLocation = confirm('Delete location');
if (deleteLocation) {
alert('Location deleted');
}
然后我突然想到:你可以通过alert(), confirm()和prompt()免费获得很多与模态相关的特性,而这些特性经常被忽略:
- 这是一个真正的模态。例如,它总是在堆栈的顶部——即在<div>为z-index: 99999;之上。
- 它可以通过键盘访问。按Enter接受和Escape取消。
- 它对屏幕阅读器很友好。它移动焦点并允许大声朗读模态内容。
- 它会吸引注意力。按下Tab不会到达主页上的任何可聚焦元素,但在Firefox和Safari中,它确实会将焦点移到浏览器UI。奇怪的是,您无法使用该Tab键将焦点移至任何浏览器中的“接受”或“取消”按钮。
- 它支持用户偏好。我们开箱即可获得自动明暗模式支持。
- 它暂停代码执行。另外,它等待用户输入。
当我需要这些功能中的任何一个时,这三种JavaScript方法99%的时间都可以工作。那么为什么我——或者真的是任何其他网络开发者——不使用它们呢?可能是因为它们看起来像无法设置样式的系统错误。另一个重要的考虑因素是:它们已经被弃用了。首先从跨域iframe中删除,并且,换句话说,完全从Web平台中删除,尽管听起来这方面的计划也被搁置了。
考虑到这一点,我们必须用哪些alert()、confirm()和prompt()替代它们呢?您可能已经听说过<dialog>HTML 元素,这就是我想在本文中看到的内容,将它与JavaScript一起使用class。
用相同的功能完全替换Javascript对话框是不可能的,但是如果我们使用与可以(接受)或(取消)相结合的showModal()方法,那么我们就有了几乎一样好的东西。哎呀,当我们这样做的时候,让我们为HTML对话框元素添加声音——就像真正的系统对话框一样!
如果您想立即观看演示,请点击此处。
一个对话框类
首先,我们需要一个基本的JavaScript Class,它带有一个设置对象,将与默认设置合并。这些设置将用于所有对话框,除非您在调用它们时覆盖它们(但稍后会详细介绍)。
export default class Dialog {
constructor(settings = {}) {
this.settings = Object.assign(
{
/* DEFAULT SETTINGS - see description below */
},
settings
)
this.init()
}
设置如下:
- accept:这是“接受”按钮的标签。
- bodyClass:这是一个CSS类,当浏览器支持和不支持<dialog>对话框打开时添加到元素<body>中。
- cancel:这是“取消”按钮的标签。
- dialogClass:这是添加到<dialog>元素的自定义CSS类。
- message:这是<dialog>里面的内容。
- soundAccept:这是当用户点击“接受”按钮时我们将播放的声音文件的URL。
- soundOpen:这是用户打开对话框时我们将播放的声音文件的URL。
- template:这是一个可选的小HTML模板,它被注入到<dialog>中。
替换JavaScript对话框的初始模板
在该init方法中,我们将添加一个帮助函数来检测浏览器对HTML对话框元素的支持,并设置基本HTML:
init() {
// Testing for <dialog> support
this.dialogSupported = typeof HTMLDialogElement === 'function'
this.dialog = document.createElement('dialog')
this.dialog.dataset.component = this.dialogSupported ? 'dialog' : 'no-dialog'
this.dialog.role = 'dialog'
// HTML template
this.dialog.innerHTML = `
<form method="dialog" data-ref="form">
<fieldset data-ref="fieldset" role="document">
<legend data-ref="message" id="${(Math.round(Date.now())).toString(36)}">
</legend>
<div data-ref="template"></div>
</fieldset>
<menu>
<button data-ref="cancel" value="cancel"></button>
<button data-ref="accept" value="default"></button>
</menu>
<audio data-ref="soundAccept"></audio>
<audio data-ref="soundOpen"></audio>
</form>`
document.body.appendChild(this.dialog)
// ...
}
检查支持
浏览器支持<dialog>的道路很长。Safari最近才支持它。火狐甚至也是最近,虽然不是<form method="dialog">一部分。因此,我们需要添加type="button"到我们正在模仿的“接受”和“取消”按钮。否则,它们将POST表单并导致页面刷新,我们希望避免这种情况。
<button${this.dialogSupported ? '' : ` type="button"`}...></button>
DOM节点引用
你注意到所有的data-ref-attribuites了吗?我们将使用这些来获取对DOM节点的引用:
this.elements = {}
this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)
到目前为止,this.elements.accept引用的是“Accept”按钮,而this.elements.cancel引用的是“Cancel”按钮。
按钮属性
对于屏幕阅读器,我们需要一个指向描述对话框的标签ID的aria-labelledby属性——即<legend>标签,它将包含message。
this.dialog.setAttribute('aria-labelledby', this.elements.message.id)
那id呢?这是对这部分<legend>元素的唯一引用:
“取消”按钮
好消息!HTML对话框元素有一个内置cancel()方法,可以更轻松地替换调用该confirm()方法的JavaScript对话框。让我们在单击“取消”按钮时发出该事件:
this.elements.cancel.addEventListener('click', () => {
this.dialog.dispatchEvent(new Event('cancel'))
})
这是我们<dialog>替换alert(),confirm()和prompt()的框架。
填充不支持的浏览器
我们需要为不支持它的浏览器隐藏HTML对话框元素。为此,我们将在一个新toggle()方法中包装显示和隐藏对话框的逻辑:
toggle(open = false) {
if (this.dialogSupported && open) this.dialog.showModal()
if (!this.dialogSupported) {
document.body.classList.toggle(this.settings.bodyClass, open)
this.dialog.hidden = !open
/* If a `target` exists, set focus on it when closing */
if (this.elements.target && !open) {
this.elements.target.focus()
}
}
}
/* Then call it at the end of `init`: */
this.toggle()
键盘导航
接下来,让我们实现一种捕获焦点的方法,以便用户可以在对话框中的按钮之间切换,而不会无意中退出对话框。有很多方法可以做到这一点。我喜欢CSS 方式,但不幸的是,它不可靠。相反,让我们从对话框中获取所有可聚焦元素作为NodeList并将其存储在this.focusable中:
getFocusable() {
return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])')]
}
接下来,我们将添加一个keydown事件监听器,处理我们所有的键盘导航逻辑:
this.dialog.addEventListener('keydown', e => {
if (e.key === 'Enter') {
if (!this.dialogSupported) e.preventDefault()
this.elements.accept.dispatchEvent(new Event('click'))
}
if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel'))
if (e.key === 'Tab') {
e.preventDefault()
const len = this.focusable.length - 1;
let index = this.focusable.indexOf(e.target);
index = e.shiftKey ? index-1 : index+1;
if (index < 0) index = len;
if (index > len) index = 0;
this.focusable[index].focus();
}
})
对于Enter,我们需要防止在不支持该<form>元素的浏览器中提交。将发出一个事件。按Tab键将在可聚焦元素的节点列表中找到当前元素,并将焦点设置在下一项(如果同时按住Shift键,则为上一项)。
显示<dialog>
现在让我们显示对话框!为此,我们需要一个将可选settings对象与默认值合并的小方法。在这个对象中——就像默认settings对象一样——我们可以添加或更改特定对话框的设置。
open(settings = {}) {
const dialog = Object.assign({}, this.settings, settings)
this.dialog.className = dialog.dialogClass || ''
/* set innerText of the elements */
this.elements.accept.innerText = dialog.accept
this.elements.cancel.innerText = dialog.cancel
this.elements.cancel.hidden = dialog.cancel === ''
this.elements.message.innerText = dialog.message
/* If sounds exists, update `src` */
this.elements.soundAccept.src = dialog.soundAccept || ''
this.elements.soundOpen.src = dialog.soundOpen || ''
/* A target can be added (from the element invoking the dialog */
this.elements.target = dialog.target || ''
/* Optional HTML for custom dialogs */
this.elements.template.innerHTML = dialog.template || ''
/* Grab focusable elements */
this.focusable = this.getFocusable()
this.hasFormData = this.elements.fieldset.elements.length > 0
if (dialog.soundOpen) {
this.elements.soundOpen.play()
}
this.toggle(true)
if (this.hasFormData) {
/* If form elements exist, focus on that first */
this.focusable[0].focus()
this.focusable[0].select()
}
else {
this.elements.accept.focus()
}
}
那是很多代码。现在我们可以在所有浏览器中显示该<dialog>元素。但是我们仍然需要模仿执行后等待用户输入的功能,比如原生的alert()、confirm()和prompt()方法。为此,我们需要一个Promise和我正在调用的新waitForUser()方法:
waitForUser() {
return new Promise(resolve => {
this.dialog.addEventListener('cancel', () => {
this.toggle()
resolve(false)
}, { once: true })
this.elements.accept.addEventListener('click', () => {
let value = this.hasFormData ?
this.collectFormData(new FormData(this.elements.form)) : true;
if (this.elements.soundAccept.src) this.elements.soundAccept.play()
this.toggle()
resolve(value)
}, { once: true })
})
}
此方法返回一个Promise。在其中,我们为“取消”和“接受”添加事件侦听器,它们可以解析false(取消)或true(接受)。如果formData存在(对于自定义对话框或prompt),这些将使用辅助方法收集,然后在对象中返回:
collectFormData(formData) {
const object = {};
formData.forEach((value, key) => {
if (!Reflect.has(object, key)) {
object[key] = value
return
}
if (!Array.isArray(object[key])) {
object[key] = [object[key]]
}
object[key].push(value)
})
return object
}
我们可以立即删除事件监听器,使用{ once: true }。
注:为了简单起见,我不使用reject(),而是简单地使用resolve false。
隐藏<dialog>
早些时候,我们为内置cancel事件添加了事件侦听器。当用户单击“取消”按钮或按Escape键时,我们调用此事件。该cancel事件删除<dialog>上的open属性,从而隐藏它。
去哪里:focus?
在我们的open()方法中,我们关注第一个可聚焦的表单字段或“接受”按钮:
if (this.hasFormData) {
this.focusable[0].focus()
this.focusable[0].select()
}
else {
this.elements.accept.focus()
}
但这是正确的吗?在W3的“Modal Dialog”示例中,确实如此。不过,在Scott Ohara的示例中,重点是对话框本身——如果屏幕阅读器应该阅读我们之前在aria-labelledby属性中定义的文本,这是有道理的。我不确定哪个是正确的或最好的,但如果我们想使用Scott的方法。我们需要在我们的init方法中添加一个tabindex="-1"到<dialog>中:
this.dialog.tabIndex = -1
然后,在open()方法中,我们将焦点代码替换为:
this.dialog.focus()
我们可以在DevTools中的任何给定时间通过单击“眼睛”图标并在控制台中输入document.activeElement来检查activeElement(具有焦点的元素) 。尝试四处切换以查看它的更新:
添加警报、确认和提示
我们终于准备好将alert(),confirm()和prompt()添加到我们的Dialog类中了。这些将是替代JavaScript对话框和这些方法的原始语法的小型辅助方法。它们都调用我们之前创建的open()方法,但使用的settings对象与我们触发原始方法的方式相匹配。
让我们与原始语法进行比较。
alert()通常是这样触发的:
window.alert(message);
在我们的对话框中,我们将添加一个模仿这个的alert()方法:
/* dialog.alert() */
alert(message, config = { target: event.target }) {
const settings = Object.assign({}, config, { cancel: '', message, template: '' })
this.open(settings)
return this.waitForUser()
}
我们将canceland和template设置为空字符串,这样——即使我们之前已经设置了默认值——这些也不会被隐藏,而只会message和accept被显示。
confirm()通常是这样触发的:
window.confirm(message);
在我们的版本中,与alert()类似,我们创建了一个自定义方法来显示message、cancel和accept项:
/* dialog.confirm() */
confirm(message, config = { target: event.target }) {
const settings = Object.assign({}, config, { message, template: '' })
this.open(settings)
return this.waitForUser()
}
prompt()通常是这样触发的:
window.prompt(message, default);
在这里,我们需要添加一个带有<input>的template,我们将它包装在 <label>中:
/* dialog.prompt() */
prompt(message, value, config = { target: event.target }) {
const template = `
<label aria-label="${message}">
<input name="prompt" value="${value}">
</label>`
const settings = Object.assign({}, config, { message, template })
this.open(settings)
return this.waitForUser()
}
注:{ target: event.target }是对调用该方法的DOM元素的引用。当我们关闭 时,我们将使用它来重新关注该<dialog>元素,将用户返回到触发对话框之前的位置。
我们应该测试一下
是时候测试并确保一切都按预期工作了。让我们创建一个新的HTML文件,导入类,然后创建一个实例:
<script type="module">
import Dialog from './dialog.js';
const dialog = new Dialog();
</script>
一次尝试以下用例!
/* alert */
dialog.alert('Please refresh your browser')
/* or */
dialog.alert('Please refresh your browser').then((res) => { console.log(res) })
/* confirm */
dialog.confirm('Do you want to continue?').then((res) => { console.log(res) })
/* prompt */
dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })
然后在单击“接受”或“取消”时观看控制台。请在按Escape或Enter键时重试。
异步/等待
我们也可以使用async/await这样做的方式。我们通过模仿原始语法来更多地替换JavaScript对话框,但它需要包装函数是async,而其中的代码需要await关键字:
document.getElementById('promptButton').addEventListener('click', async (e) => {
const value = await dialog.prompt('The meaning of life?', 42);
console.log(value);
});
跨浏览器样式
我们现在拥有一个功能齐全的跨浏览器和屏幕阅读器友好的HTML对话框元素,它取代了JavaScript对话框!我们已经介绍了很多。但造型可以用很多的爱。让我们利用现有的data-component和data-ref-attributes 来添加跨浏览器样式——不需要额外的类或其他属性!
我们将使用CSS:where伪选择器来保持我们的默认样式不受特殊性影响:
:where([data-component*="dialog"] *) {
box-sizing: border-box;
outline-color: var(--dlg-outline-c, hsl(218, 79.19%, 35%))
}
:where([data-component*="dialog"]) {
--dlg-gap: 1em;
background: var(--dlg-bg, #fff);
border: var(--dlg-b, 0);
border-radius: var(--dlg-bdrs, 0.25em);
box-shadow: var(--dlg-bxsh, 0px 25px 50px -12px rgba(0, 0, 0, 0.25));
font-family:var(--dlg-ff, ui-sansserif, system-ui, sans-serif);
min-inline-size: var(--dlg-mis, auto);
padding: var(--dlg-p, var(--dlg-gap));
width: var(--dlg-w, fit-content);
}
:where([data-component="no-dialog"]:not([hidden])) {
display: block;
inset-block-start: var(--dlg-gap);
inset-inline-start: 50%;
position: fixed;
transform: translateX(-50%);
}
:where([data-component*="dialog"] menu) {
display: flex;
gap: calc(var(--dlg-gap) / 2);
justify-content: var(--dlg-menu-jc, flex-end);
margin: 0;
padding: 0;
}
:where([data-component*="dialog"] menu button) {
background-color: var(--dlg-button-bgc);
border: 0;
border-radius: var(--dlg-bdrs, 0.25em);
color: var(--dlg-button-c);
font-size: var(--dlg-button-fz, 0.8em);
padding: var(--dlg-button-p, 0.65em 1.5em);
}
:where([data-component*="dialog"] [data-ref="accept"]) {
--dlg-button-bgc: var(--dlg-accept-bgc, hsl(218, 79.19%, 46.08%));
--dlg-button-c: var(--dlg-accept-c, #fff);
}
:where([data-component*="dialog"] [data-ref="cancel"]) {
--dlg-button-bgc: var(--dlg-cancel-bgc, transparent);
--dlg-button-c: var(--dlg-cancel-c, inherit);
}
:where([data-component*="dialog"] [data-ref="fieldset"]) {
border: 0;
margin: unset;
padding: unset;
}
:where([data-component*="dialog"] [data-ref="message"]) {
font-size: var(--dlg-message-fz, 1.25em);
margin-block-end: var(--dlg-gap);
}
:where([data-component*="dialog"] [data-ref="template"]:not(:empty)) {
margin-block-end: var(--dlg-gap);
width: 100%;
}
当然,您可以根据需要设计这些样式。以下是上述CSS将为您提供的内容:
alert()
confirm()
prompt()
要覆盖这些样式并使用您自己的样式,请在dialogClass中添加一个类,
dialogClass: 'custom'
…然后在CSS中添加类,并更新CSS自定义属性值:
.custom {
--dlg-accept-bgc: hsl(159, 65%, 75%);
--dlg-accept-c: #000;
/* etc. */
}
自定义对话框示例
如果我们模仿的标准alert()、confirm()和prompt()方法无法满足您的特定用例怎么办?实际上,我们可以做更多的事情来使其<dialog>更灵活,以涵盖比我们迄今为止所涵盖的内容、按钮和功能更多的内容——而且工作量也不大。
早些时候,我曾讨论过在对话中添加声音的想法。让我们这样做。
您可以使用settings对象的template属性来注入更多的HTML。这是一个自定义示例,从带有id="btnCustom"的<button>调用,从MP3文件中触发一个有趣的小声音:
document.getElementById('btnCustom').addEventListener('click', (e) => {
dialog.open({
accept: 'Sign in',
dialogClass: 'custom',
message: 'Please enter your credentials',
soundAccept: 'https://assets.yourdomain.com/accept.mp3',
soundOpen: 'https://assets.yourdomain.com/open.mp3',
target: e.target,
template: `
<label>Username<input type="text" name="username" value="admin"></label>
<label>Password<input type="password" name="password" value="password"></label>`
})
dialog.waitForUser().then((res) => { console.log(res) })
});
现场演示
这是一支包含我们构建的所有东西的钢笔!打开控制台,单击按钮,然后玩弄对话框,单击按钮并使用键盘接受和取消。
所以你怎么看?这是用较新的HTML对话框元素替换JavaScript对话框的好方法吗?或者你有没有尝试过另一种方式?在评论中告诉我!
https://css-tricks.com/replace-javascript-dialogs-html-dialog-element/