在 LWC 中为自定义 SLDS 模态框优雅实现 JavaScript 焦点陷阱
第一步:识别模态框内可聚焦的元素
第二步:监听键盘事件 (Tab 和 Shift+Tab)
第三步:实现焦点陷阱逻辑 (trapFocus)
第四步:结合 LWC 生命周期 (renderedCallback)
第五步:考虑动态内容
总结
在 Salesforce Lightning Web Components (LWC) 中构建用户界面时,模态框(Modal Dialog)是常见的交互模式。为了保证良好的可访问性(Accessibility),特别是对于键盘用户,当模态框弹出时,用户的焦点(Focus)应该被“困”在模态框内部。用户不能通过按 Tab
键将焦点移出模态框,切换到模态框后面的页面元素。这种机制通常被称为“焦点陷阱”(Focus Trap)。
虽然 LWC 提供了一些基础组件可能自带模态框功能,但在许多场景下,我们需要基于 SLDS (Salesforce Lightning Design System) 蓝图和自定义 LWC 来构建更灵活的模态框。这时,焦点陷阱就需要我们手动实现。这篇文章将深入探讨如何使用原生 JavaScript,结合 LWC 的生命周期钩子和 DOM 查询 API,优雅地为自定义 SLDS 模态框实现焦点陷阱。
我们的目标是:
- 监听
Tab
和Shift+Tab
按键事件。 - 动态查找模态框内所有可聚焦的元素。
- 在用户尝试将焦点移出模态框时,将焦点循环限制在模态框内部。
第一步:识别模态框内可聚焦的元素
焦点陷阱的核心在于知道哪些元素是可以接收焦点的。在 Web 中,可聚焦元素通常包括:
- 带有
href
属性的<a>
标签 <button>
标签(未被disabled
)<input>
标签(未被disabled
且类型不是hidden
)<select>
标签(未被disabled
)<textarea>
标签(未被disabled
)- 任何设置了
tabindex="0"
的元素 - 某些情况下,
<area>
或带有contenteditable
属性的元素也可能需要考虑,但前几种最常见。
我们可以构建一个 CSS 选择器字符串来匹配这些元素。一个相对健壮的选择器可能是这样的:
a[href]:not([tabindex='-1']), button:not([disabled]):not([tabindex='-1']), textarea:not([disabled]):not([tabindex='-1']), input:not([type='hidden']):not([disabled]):not([tabindex='-1']), select:not([disabled]):not([tabindex='-1']), [tabindex]:not([tabindex='-1'])
这个选择器排除了被明确设置为 tabindex="-1"
(意味着程序化可聚焦,但不能通过 Tab 键导航)和 disabled
状态的元素。
在 LWC 组件的 JavaScript 文件中,我们可以使用 this.template.querySelectorAll()
方法,在组件的 Shadow DOM 内部查找匹配这些选择器的元素。这一步通常在模态框内容渲染完成后执行。
// 在 LWC 组件的 JS 文件中 get focusableElements() { const selector = ` a[href]:not([tabindex='-1']), button:not([disabled]):not([tabindex='-1']), textarea:not([disabled]):not([tabindex='-1']), input:not([type='hidden']):not([disabled]):not([tabindex='-1']), select:not([disabled]):not([tabindex='-1']), [tabindex]:not([tabindex='-1']) `; // 假设模态框的根元素有一个特定的 class 或 data-attribute,例如 'modal-container' const modalContainer = this.template.querySelector('.modal-container'); if (!modalContainer) { return []; } // 只查找模态框内部的可聚焦元素 return Array.from(modalContainer.querySelectorAll(selector)); }
注意: 这个查找操作应该在模态框可见且其内容已经渲染之后进行。我们稍后会讨论如何结合 LWC 生命周期钩子来做这件事。
第二步:监听键盘事件 (Tab 和 Shift+Tab)
我们需要监听键盘的 keydown
事件,以便在用户按下 Tab
键时介入。为什么是 keydown
而不是 keyup
?因为我们需要在浏览器默认的焦点切换行为发生之前阻止它 (event.preventDefault()
)。
这个事件监听器应该附加在模态框的容器元素上,或者在 LWC 组件的根元素上,确保能捕获到模态框内部触发的键盘事件。
// 在 LWC 组件的 JS 文件中 handleKeyDown(event) { // 检查是否是 Tab 键被按下 if (event.key === 'Tab' || event.keyCode === 9) { // 这里将实现焦点陷阱逻辑 this.trapFocus(event); } // (可选) 处理 Esc 键关闭模态框 if (event.key === 'Escape' || event.keyCode === 27) { this.closeModal(); // 假设有一个关闭模态框的方法 } } // 需要在模板中将此处理器绑定到合适的元素上 // <div class="modal-container" onkeydown={handleKeyDown}> // ... // </div>
第三步:实现焦点陷阱逻辑 (trapFocus
)
这是焦点管理的核心。当检测到 Tab
键按下时,trapFocus
方法需要执行以下操作:
- 获取所有可聚焦元素列表: 调用我们之前定义的
focusableElements
getter。 - 检查列表是否为空: 如果模态框内没有可聚焦元素,则无需处理,直接返回(虽然这种情况比较少见,但做好防御性编程)。
- 获取第一个和最后一个可聚焦元素。
- 获取当前获得焦点的元素: 在 LWC 的 Shadow DOM 中,使用
this.template.activeElement
来获取当前组件内部的活动元素。如果焦点在 Shadow DOM 之外(理论上在模态框激活时不应该发生,但最好做检查),this.template.activeElement
可能为null
。更可靠的方式可能是document.activeElement
,但要注意它可能返回 Shadow DOM 之外的元素,需要结合判断。 - 阻止默认的 Tab 行为: 调用
event.preventDefault()
。 - 判断 Tab 方向(前进或后退): 通过
event.shiftKey
属性判断用户是否同时按下了Shift
键。 - 计算下一个要聚焦的元素:
- 前进 (Tab):
- 如果当前焦点在最后一个元素上,或者焦点不在任何一个可聚焦元素上(例如初始状态),则将焦点移动到第一个元素。
- 否则,找到当前元素在列表中的索引,将焦点移动到下一个元素。
- 后退 (Shift+Tab):
- 如果当前焦点在第一个元素上,则将焦点移动到最后一个元素。
- 否则,找到当前元素在列表中的索引,将焦点移动到上一个元素。
- 前进 (Tab):
- 设置焦点: 对计算出的目标元素调用其
focus()
方法。
下面是 trapFocus
方法的伪代码实现思路:
trapFocus(event) { const focusableEls = this.focusableElements; if (focusableEls.length === 0) { event.preventDefault(); // 即使没有可聚焦元素,也阻止 Tab 跳出 return; } const firstFocusableEl = focusableEls[0]; const lastFocusableEl = focusableEls[focusableEls.length - 1]; const currentActiveEl = this.template.activeElement; // 在 LWC Shadow DOM 内查找 // 阻止默认 Tab 行为 event.preventDefault(); const isShiftTab = event.shiftKey; if (isShiftTab) { // 处理 Shift + Tab (向后) if (currentActiveEl === firstFocusableEl || !focusableEls.includes(currentActiveEl)) { // 如果当前是第一个元素,或焦点不在预期列表内,则跳到最后一个 lastFocusableEl.focus(); } else { // 跳到前一个元素 const currentIndex = focusableEls.indexOf(currentActiveEl); focusableEls[currentIndex - 1].focus(); } } else { // 处理 Tab (向前) if (currentActiveEl === lastFocusableEl || !focusableEls.includes(currentActiveEl)) { // 如果当前是最后一个元素,或焦点不在预期列表内,则跳到第一个 firstFocusableEl.focus(); } else { // 跳到后一个元素 const currentIndex = focusableEls.indexOf(currentActiveEl); focusableEls[currentIndex + 1].focus(); } } }
第四步:结合 LWC 生命周期 (renderedCallback
)
我们需要确保在执行 DOM 查询和附加事件监听器时,相关的 DOM 结构已经渲染完成。LWC 的 renderedCallback()
是执行这类操作的理想位置。但是,renderedCallback
会在每次组件重新渲染后触发,我们通常只需要在模态框首次变得可见并渲染完成后执行一次初始化设置(查找元素、附加监听器)。
我们可以使用一个标志位(例如 hasInitializedFocusTrap
)来控制初始化逻辑只运行一次。
import { LightningElement, track } from 'lwc'; export default class ModalWithFocusTrap extends LightningElement { @track isModalOpen = false; // 控制模态框的显示/隐藏 _focusableElements = []; _firstFocusableElement = null; _lastFocusableElement = null; _hasInitializedFocusTrap = false; _keydownHandler; // --- Getter for focusable elements (as defined before) --- get focusableElements() { // ... (selector logic as before) const modalContainer = this.template.querySelector('.modal-container'); if (!modalContainer) return []; const selector = '...'; // Your selector here return Array.from(modalContainer.querySelectorAll(selector)) .filter(el => el.offsetParent !== null); // 确保元素可见 } // --- Method to open the modal --- openModal() { this.isModalOpen = true; // 重置初始化标志,因为每次打开可能需要重新设置 this._hasInitializedFocusTrap = false; } // --- Method to close the modal --- closeModal() { this.isModalOpen = false; // 清理事件监听器 this.removeKeyDownListener(); } renderedCallback() { // 确保只在模态框打开且初始化未完成时执行 if (this.isModalOpen && !this._hasInitializedFocusTrap) { this.initializeFocusTrap(); } } initializeFocusTrap() { this._focusableElements = this.focusableElements; if (this._focusableElements.length > 0) { this._firstFocusableElement = this._focusableElements[0]; this._lastFocusableElement = this._focusableElements[this._focusableElements.length - 1]; // 设置初始焦点到模态框容器或第一个元素 // 最好是模态框容器本身,如果它设置了 tabindex="-1" const modalContainer = this.template.querySelector('.modal-container'); if(modalContainer) { // 设置 tabindex=-1 使容器可编程聚焦,但不参与 Tab 顺序 // modalContainer.setAttribute('tabindex', '-1'); // modalContainer.focus(); // 或者直接聚焦第一个元素 this._firstFocusableElement.focus(); } else { // 备选:直接聚焦第一个元素 this._firstFocusableElement.focus(); } // 添加键盘事件监听器 this.addKeyDownListener(); this._hasInitializedFocusTrap = true; } else { // 如果没有可聚焦元素,可能需要将焦点设置到模态框容器上 const modalContainer = this.template.querySelector('.modal-container'); if(modalContainer) { modalContainer.setAttribute('tabindex', '-1'); // 确保容器可聚焦 modalContainer.focus(); // 仍然需要监听 Tab 键以阻止其跳出 this.addKeyDownListener(); this._hasInitializedFocusTrap = true; } } } addKeyDownListener() { // 保存处理函数的引用,以便之后移除 // 使用 .bind(this) 确保 handleKeyDown 内的 this 指向组件实例 this._keydownHandler = this.handleKeyDown.bind(this); // 将监听器附加到模态框容器 const modalContainer = this.template.querySelector('.modal-container'); if (modalContainer) { // 使用 addEventListener 添加,方便后续移除 modalContainer.addEventListener('keydown', this._keydownHandler); } } removeKeyDownListener() { const modalContainer = this.template.querySelector('.modal-container'); if (modalContainer && this._keydownHandler) { modalContainer.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; // 清除引用 } } handleKeyDown(event) { if (event.key === 'Tab' || event.keyCode === 9) { this.trapFocus(event); } if (event.key === 'Escape' || event.keyCode === 27) { this.closeModal(); } } trapFocus(event) { // 如果 _focusableElements 是在 initializeFocusTrap 中设置的,可以直接使用 if (this._focusableElements.length === 0) { event.preventDefault(); return; } const currentActiveEl = this.template.activeElement; event.preventDefault(); const isShiftTab = event.shiftKey; if (isShiftTab) { if (currentActiveEl === this._firstFocusableElement || !this._focusableElements.includes(currentActiveEl)) { this._lastFocusableElement.focus(); } else { const currentIndex = this._focusableElements.indexOf(currentActiveEl); this._focusableElements[currentIndex - 1].focus(); } } else { if (currentActiveEl === this._lastFocusableElement || !this._focusableElements.includes(currentActiveEl)) { this._firstFocusableElement.focus(); } else { const currentIndex = this._focusableElements.indexOf(currentActiveEl); this._focusableElements[currentIndex + 1].focus(); } } } // 组件销毁时清理资源 disconnectedCallback() { this.removeKeyDownListener(); } }
关键点解释:
renderedCallback
和_hasInitializedFocusTrap
标志: 确保焦点陷阱的初始化(DOM查询、设置初始焦点、添加监听器)只在模态框打开后的第一次渲染时执行一次。每次重新打开模态框时,_hasInitializedFocusTrap
应重置为false
。- 初始焦点设置: 模态框打开时,应将焦点设置到模态框内的某个元素。通常是第一个可聚焦元素,或者是模态框容器本身(如果设置了
tabindex="-1"
)。这对于屏幕阅读器用户尤为重要。 - 事件监听器的添加与移除: 使用
addEventListener
添加监听器,并在模态框关闭 (closeModal
) 或组件销毁 (disconnectedCallback
) 时使用removeEventListener
移除它。保存对绑定后的处理函数 (this._keydownHandler
) 的引用是正确移除监听器的关键。 - 过滤不可见元素: 在
focusableElements
getter 中,增加了.filter(el => el.offsetParent !== null)
。这是一个简单的可见性检查,可以过滤掉display: none
或visibility: hidden
的元素。对于更复杂的可见性场景,可能需要更完善的检查。 this.template.activeElement
vsdocument.activeElement
: 在 LWC 的 Shadow DOM 中,this.template.activeElement
通常更适合用来查找组件内部的当前焦点元素。但如果焦点可能由于某种原因移到了 Shadow DOM 之外(例如,打开模态框前焦点在外部,然后没有成功设置初始焦点),document.activeElement
可能是最后的希望,但需要谨慎处理,因为它返回的是全局的活动元素。
第五步:考虑动态内容
如果模态框内的内容是动态变化的(例如,根据用户操作添加或删除了表单字段),那么可聚焦元素的列表也需要更新。在这种情况下:
- 你可能需要在内容变化后,手动重新调用
initializeFocusTrap
或一个专门更新焦点列表的方法。 - 或者,不在
initializeFocusTrap
中缓存_focusableElements
、_firstFocusableElement
、_lastFocusableElement
,而是在每次handleKeyDown
时都重新调用this.focusableElements
getter 来获取最新的列表。这会稍微增加keydown
事件处理的开销,但能保证处理的是最新的 DOM 状态。对于大多数模态框,这种开销通常可以接受。
选择哪种方式取决于模态框内容的动态程度和性能要求。
总结
在 LWC 中为自定义 SLDS 模态框实现焦点陷阱,关键在于结合 LWC 的特性(this.template.querySelectorAll
, renderedCallback
, disconnectedCallback
, this.template.activeElement
)和标准的 Web API(addEventListener
, removeEventListener
, event.preventDefault()
, element.focus()
)。
核心步骤包括:
- 定义并查询模态框内的可聚焦元素。
- 在模态框容器上监听
keydown
事件。 - 处理
Tab
和Shift+Tab
按键,阻止默认行为,并根据当前焦点位置计算下一个焦点目标,确保焦点始终循环在可聚焦元素列表内。 - 利用
renderedCallback
初始化焦点陷阱(设置初始焦点、附加监听器),并注意只执行一次。 - 在模态框关闭或组件销毁时清理事件监听器。
通过遵循这些步骤,你可以构建出不仅外观符合 SLDS 标准,而且交互行为也符合可访问性要求的自定义 LWC 模态框,为所有用户提供更好的体验。记住,无障碍设计不是事后添加的功能,而是在开发过程中就应该考虑的重要方面。