用新的 HTML 对话框元素替换 JavaScript 对话框

Avatar of Mads Stoumann
Mads Stoumann

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费信用额度!

您知道如何使用 JavaScript 对话框来提醒、确认和提示用户操作吗? 假设您想用新的 HTML 对话框元素替换 JavaScript 对话框。

让我解释一下。

我最近在一个项目中使用了很多 API 调用,并使用 JavaScript 对话框收集用户反馈。 当我等待另一位开发人员编写 <Modal /> 组件时,我在代码中使用了 alert()confirm()prompt()。 例如

const deleteLocation = confirm('Delete location');
if (deleteLocation) {
  alert('Location deleted');
}

然后我意识到:使用 alert()confirm()prompt(),您可以免费获得许多模态相关的功能,而这些功能通常被忽视

  • 它是一个真正的模态。 也就是说,它始终位于堆栈的顶部——即使位于具有 z-index: 99999;<div> 的顶部。
  • 它可以通过键盘访问。Enter 键确认,按 Escape 键取消。
  • 它对屏幕阅读器友好。 它会移动焦点,并允许朗读模态内容。
  • 它会捕获焦点。Tab 键将无法到达主页面上的任何可聚焦元素,但在 Firefox 和 Safari 中,它确实会将焦点移动到浏览器 UI。 奇怪的是,在任何浏览器中,您都无法使用 Tab 键将焦点移动到“接受”或“取消”按钮。
  • 它支持用户偏好。 我们开箱即用地获得了自动明暗模式支持。
  • 它会暂停代码执行。 此外,它会等待用户输入。

当需要这些功能中的任何一项时,这三种 JavaScript 方法 99% 的时间都能正常工作。 那为什么我——或者任何其他 Web 开发人员——不使用它们呢? 这可能是因为它们看起来像无法进行样式设置的系统错误。 另一个重要考虑因素是:它们正在逐步被弃用。 首先从跨域 iframe 中移除,而且有消息称,它们将从 Web 平台上完全移除,尽管它也 听起来像这项计划已暂停。

考虑到这个重要因素,我们有哪些 alert()confirm()prompt() 的替代方案来替换它们? 您可能已经听说过 <dialog> HTML 元素,这就是我想在这篇文章中介绍的内容,我将使用它与 JavaScript class 结合使用。

无法完全用具有相同功能的 JavaScript 对话框替换它们,但如果我们将 <dialog>showModal() 方法与可以 resolve(接受)或 reject(取消)的 Promise 结合使用——那么我们就可以得到一种几乎一样好的方法。 哎呀,趁着我们还在,让我们在 HTML 对话框元素中添加声音——就像真正的系统对话框一样!

如果您想立即查看演示,这里有

一个对话框类

首先,我们需要一个基本的 JavaScript Class,它包含一个 settings 对象,该对象将与默认设置合并。 这些设置将用于所有对话框,除非您在调用它们时覆盖它们(但稍后会详细介绍)。

export default class Dialog {
constructor(settings = {}) {
  this.settings = Object.assign(
    {
      /* DEFAULT SETTINGS - see description below */
    },
    settings
  )
  this.init()
}

设置如下

  • accept: 这是“接受”按钮的标签。
  • bodyClass: 这是一个 CSS 类,当对话框处于 open 状态且浏览器不支持 <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 最近才 开始支持它。 Firefox 甚至更近才开始支持它,但它并不支持 <form method="dialog"> 部分。 所以,我们需要在模拟的“接受”和“取消”按钮中添加 type="button"。 否则,它们会 POST 表单并导致页面刷新,而我们想要避免这种情况。

<button${this.dialogSupported ? '' : ` type="button"`}...></button>

DOM 节点引用

您注意到所有 data-ref 属性了吗? 我们将使用它们来获取 DOM 节点的引用

this.elements = {}
this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)

到目前为止,this.elements.accept 是对“接受”按钮的引用,this.elements.cancel 是对“取消”按钮的引用。

按钮属性

对于屏幕阅读器,我们需要一个 aria-labelledby 属性,它指向描述对话框的标签的 ID——即 <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=&quot;hidden&quot;]),[tabindex]:not([tabindex=&quot;-1&quot;])')]
}

接下来,我们将添加一个 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> 在不支持 <dialog> 元素的浏览器中提交。 Escape 会发出 cancel 事件。 按下 Tab 键会找到可聚焦元素节点列表 this.focusable 中的当前元素,并将焦点设置在下一个项目上(如果同时按住 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。在其中,我们添加了 “cancel” 和 “accept” 的事件监听器,它们分别解析为 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(),而是简单地解析 false

隐藏 <dialog>

早些时候,我们为内置的 cancel 事件添加了事件监听器。当用户点击 “cancel” 按钮 *或* 按下 Escape 键时,我们调用此事件。cancel 事件会从 <dialog> 上移除 open 属性,从而将其隐藏。

:focus 到哪里?

在我们的 open() 方法中,我们将焦点放在第一个可聚焦表单字段 *或* “Accept” 按钮上

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 方法中向 <dialog> 添加一个 tabindex="-1"

this.dialog.tabIndex = -1

然后,在 open() 方法中,我们将用以下代码替换焦点代码

this.dialog.focus()

我们可以通过点击 “eye” 图标并在控制台中输入 document.activeElement 来随时检查 activeElement(具有焦点的元素)。尝试在周围切换选项卡以查看其更新

Showing the eye icon in DevTools, highlighted in bright green.
点击 “eye” 图标

添加 alert、confirm 和 prompt

我们终于可以将 alert()confirm()prompt() 添加到我们的 Dialog 类中。这些将是替换 JavaScript 对话框和这些方法的原始语法的简单辅助方法。它们都调用我们之前创建的 open() 方法,但使用与我们触发原始方法的方式相匹配的 settings 对象。

让我们与原始语法进行比较。

alert() 通常是这样触发的

window.alert(message);

在我们的 Dialog 中,我们将添加一个 alert() 方法,它将模仿这一点

/* dialog.alert() */
alert(message, config = { target: event.target }) {
  const settings = Object.assign({}, config, { cancel: '', message, template: '' })
  this.open(settings)
  return this.waitForUser()
}

我们将 canceltemplate 设置为空字符串,这样——即使我们之前设置了默认值——这些将不会被隐藏,只有 messageaccept 会显示。

confirm() 通常是这样触发的

window.confirm(message);

在我们的版本中,类似于 alert(),我们创建了一个自定义方法,它显示 messagecancelaccept 项目

/* 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) })

然后在你点击 “Accept” 或 “Cancel” 时观察控制台。尝试再次按下 EscapeEnter 键,而不是点击。

Async/Await

我们也可以使用 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-componentdata-ref 属性添加跨浏览器样式——无需额外的类或其他属性!

我们将使用 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 会为你提供的内容

Showing how to replace JavaScript dialogs that use the alert method. The modal is white against a gray background. The content reads please refresh your browser and is followed by a blue button with a white label that says OK.
alert()
Showing how to replace JavaScript dialogs that use the confirm method. The modal is white against a gray background. The content reads please do you want to continue? and is followed by a black link that says cancel, and a blue button with a white label that says OK.
confirm()
Showing how to replace JavaScript dialogs that use the prompt method. The modal is white against a gray background. The content reads the meaning of life, and is followed by a a text input filled with the number 42, which is followed by a black link that says cancel, and a blue button with a white label that says OK.
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 对话框的好方法吗?或者你试过用其他方法吗?在评论中告诉我!