在我之前的文章中,该文章现在将追溯性地称为 使用可复用 JavaScript 函数管理 CSS 中的状态 – 第 1 部分,我们创建了一个强大的可复用函数,它允许我们通过点击快速添加、删除和切换状态类。
我想分享这种方法的原因之一是看看它会产生什么反应。 从那时起,我收到了来自其他开发人员的一些有趣的反馈,一些开发人员提出了关于这种方法的有效缺点,这些缺点我之前从未想过。
在本文中,我将提供一些解决这些缺点的方案,以及更多功能和一般改进,使我们的可复用函数更加强大。
文章系列
- 原文
- 使用可复用 JavaScript 函数管理 CSS 中的状态(您现在就在这里!)
作为参考,以下是我们可复用函数的 JavaScript 代码,来自 第 1 部分
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-class-behaviour list if present and convert to array
if(this.getAttribute("data-class-behaviour")) {
var dataClassBehaviour = this.getAttribute("data-class-behaviour");
dataClassBehaviour = dataClassBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Grab behaviour if any exists
if(dataClassBehaviour) {
var elemBehaviour = dataClassBehaviour[b];
}
// Do
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
}
});
}
展望未来,这将作为我们改进的基础。
让我们开始吧!
可访问性
针对 第 1 部分,我从其他开发人员那里收到的最常见的反馈是,这种方法缺乏对可访问性的考虑。 更具体地说,它缺乏对 ARIA 属性(或如果您愿意,ARIA 状态)的支持,并且无法为触发我们的可复用函数提供键盘事件。
让我们看看如何集成两者。
ARIA 属性
ARIA 属性是 WAI-ARIA 规范 的一部分。 用规范中的话来说,它们……
……用于支持各种操作系统平台上的平台辅助功能 API。 辅助技术可以通过公开的用户代理 DOM 或通过映射到平台辅助功能 API 来访问此信息。 当与角色结合使用时,用户代理可以向辅助技术提供用户界面信息,以便随时传达给用户。 状态或属性的变化会导致向辅助技术发出通知,这可能会提醒用户发生了变化。
重新审视来自 第 1 部分 的手风琴示例,aria-expanded
属性设置为 true
(当组件展开时),反之亦然(当处于默认状态时),将允许屏幕阅读器等辅助技术更好地评估组件。
除了提供这些好处之外,正如 Ben Frain 在他的 文章 中探讨的那样,我们可以放弃状态类,而是依赖 ARIA 属性作为我们 CSS 钩子来设置一些组件状态的样式。
采用这种方法会产生一种(令人不寒而栗的)被称为“双赢”的局面。 我们既可以提高 Web 应用程序的可访问性,又可以获得一个明确定义、经过深思熟虑的词汇表,用于传达应用程序逻辑中所需的状态。
例如,而不是
.c-accordion.is-active .c-accordion__content {
[...]
}
我们将有
.c-accordion[aria-expanded="true"] .c-accordion__content {
[...]
}
回到我们的可复用函数,我们将构建支持,以便 data-class
属性还可以接受 ARIA 属性引用。 由于我们现在操作的是属性,而不仅仅是类,所以从语义上讲,将 data-class
及其所有关联属性重命名为 data-state
更有意义。
<div class="c-mycomponent" data-state="aria-expanded" data-state-element="c-mycomponent" aria-expanded="false" tabindex="0">
在上面的示例中,单击 c-mycomponent
应该在自身上切换 aria-expanded
。 而在下面的示例中,除了前面的行为之外,my-class
将从 c-myothercomponent
中删除。
<div class="c-mycomponent" data-state="aria-expanded, my-class" data-state-element="c-mycomponent, c-myothercomponent" data-state-behaviour="toggle, remove" aria-expanded="false" tabindex="0">
除了 aria-expanded
之外,ARIA 属性如何代替状态类的其他示例还有
aria-disabled="true"
代替is-disabled
aria-checked="true"
代替is-checked
aria-pressed="true"
或aria-selected="true"
代替is-active
这里有一个 方便的 ARIA 速查表,在撰写本文时非常有用。
实施
我们的可复用函数目前假设传递给它的所有内容,通过我们新命名的 data-state
属性,都是一个类。 然后,它根据 data-state-behaviour
中定义的内容或其默认的 toggle
行为相应地进行操作。
// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
让我们稍微调整一下
// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
// Find out if we're manipulating aria-attributes or classes
var toggleAttr;
if(elemRef[c].getAttribute(elemState)) {
toggleAttr = true;
}
else {
toggleAttr = false;
}
if(elemBehaviour === "add") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, true);
}
else {
elemRef[c].classList.add(elemState);
}
}
else if(elemBehaviour === "remove") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].classList.remove(elemState);
}
}
else {
if(toggleAttr) {
if(elemRef[c].getAttribute(elemState) === "true") {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].setAttribute(elemState, true);
}
}
else {
elemRef[c].classList.toggle(elemState);
}
}
}
为了支持 ARIA 属性,我们只是添加了一个检查,首先查看给定的 ARIA 属性是否存在于元素上,如果没有,则假设它是一个类并像以前一样处理它。 这样,我们可以同时支持 ARIA 属性和类,以涵盖所有情况。 此外,classList.contains()
检查也被移除,因为在当前规范中,classList.add()
和 classList.remove()
足够智能,可以考虑到这一点。
键盘事件
为了使网站被视为可访问,它必须能够通过仅使用键盘轻松地导航和交互。 就开发人员而言,这通常涉及使用 tabindex
属性并利用键盘事件。
在大多数浏览器中,诸如锚点之类的元素默认情况下已经具有这些属性。 您可以在其中切换标签,并且当处于焦点时,它们可以在按下回车键时被激活。 但是,对于许多使用语义元素和 div 组合构建的组件,情况并非如此。
让我们通过编写逻辑来自动将键盘事件添加到触发元素,使其能够像锚点一样被激活 - 通过按下回车键,来弥补我们的可复用函数的不足。
实施
目前,由于函数逻辑是通过单击带有 data-state
和 data-state-element
属性的元素来触发的,因此所有内容都包装在一个 click
事件侦听器中。
elems[i].addEventListener("click", function(e){
// Function logic
});
由于按下回车键需要触发与单击相同的函数逻辑,因此将此逻辑分隔成它自己的函数是有意义的,以便可以从两者中触发它。 我们将其命名为 processChange()
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
// e.which refers to the key pressed, 13 being the enter key.
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
除了现有的 click
事件侦听器之外,我们还添加了额外的侦听器,以在按下回车键时做出反应。 当在聚焦的触发元素上发生匹配的 keypress
事件时,只需运行我们的新 processChange()
函数并传递元素即可。
您还会注意到没有逻辑来自动添加 tabIndex
属性。 这是因为它可能与页面上已定义的任何 tabIndex
层次结构冲突,并干扰开发人员的意图。
示例
以下是从 第 1 部分 修改的手风琴示例,但已完全更新以利用 ARIA 属性和键盘事件,使其成为更易访问的组件。 您可以在 JavaScript 面板中看到目前的可复用函数。
查看 CodePen 上 Luke Harrison 的笔 #7) 可访问性示例 (@lukedidit)。
为 DOM 之后添加的元素进行说明
在 第 1 部分 中,评论部分提出一个问题
我认为这对于 DOM 之后添加的元素来说会有一些问题。 在这种情况下,您需要重复分配点击事件。 我说对吗?
那是正确的! 任何在 DOM 初始渲染后添加的带有 data-state
和 data-state-element
属性的元素都不会有任何事件侦听器分配给它们。 因此,当它们被点击或滑动时,什么也不会发生。
为什么? 这是因为在我们的 JavaScript 中,一旦将事件侦听器分配给带有 data-state
和 data-state-element
属性的元素的初始轮次完成,就没有功能来表示“嘿! 注意任何带有 data-state
和 data-state-element
属性的新元素,并使它们发挥作用。”
实施
为了解决这个问题,我们将利用一个叫做 `MutationObserver` 的东西。虽然在 David Walsh 的 API 概述 中可以更好地解释,但 `MutationObserver` 基本上允许我们跟踪添加到 DOM 中的任何节点或从 DOM 中移除的任何节点(也称为“DOM 变异”)。
我们可以这样设置它
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state") && mutation.addedNodes[d].getAttribute("data-state-element")) {
// Create click and keyboard event listeners etc
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
这就是我们的 `MutationObserver` 在做的事情
- 记录对 body 元素的任何 DOM 变异,这些变异是其直接子元素 `childList: true` 或其后代 `subtree: true`
- 检查该 DOM 变异是否是一个新的元素节点,而不是一个文本节点
- 如果是,则检查新的元素节点是否具有 `data-state` 和 `data-state-element` 属性
下一步,假设这 3 个检查都通过了,就是设置我们的 `click` 和 `keypress` 事件监听器。与键盘事件的实现一样,让我们将这个设置逻辑分离到它自己的函数中,这样我们就可以在页面加载时和在 `MutationObserver` 检测到具有 `data-state` 和 `data-state-element` 属性的元素时重用它。
我们将调用这个新函数 `initDataState()`。
// Init function
initDataState = function(elem){
// Add event listeners to each one
elems.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
// Add keyboard event for enter key to mimic anchor functionality
elems.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
}
然后,只需正确地将所有东西连接起来
// Run when DOM has finished loading
document.addEventListener("DOMContentLoaded", function() {
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state][data-state-element]");
// Loop through if any are found
for(var a = 0; a < elems.length; b++){
initDataState(elems[a]);
}
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state")) {
initDataState(mutation.addedNodes[d]);
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
});
示例
点击“添加”按钮将更多元素插入页面(以下示例 Pen)
查看 Pen #8) Correctly set up new data-class elements when they are added to the DOM by Luke Harrison (@lukedidit) on CodePen.
滑动支持
目前,我们的可重用函数使用点击和键盘事件来触发状态逻辑。这在桌面级别是可以的,但在触摸设备上,对于某些 UI 组件(例如关闭滑动导航菜单),让这个逻辑在检测到滑动时触发往往更有用。
让我们为我们的可重用函数构建可选的滑动支持。这将需要添加一个新的数据属性来补充我们现有的集合
data-state-swipe
这个新属性的目的是允许我们定义应该触发状态逻辑的滑动方向。这些方向应该是
上
右
下
左
我们还应该构建一个选项来指定滑动事件是否应该替换点击事件,或者两者是否应该共存。我们可以向 `data-state-swipe` 添加一个用逗号分隔的布尔值来触发此行为
true
– 滑动事件监听器替换点击事件监听器false
– 添加滑动事件监听器和点击事件监听器(默认)
例如,当下面的 `div` 检测到左滑时,`js-elem` 上的 `aria-expanded` 属性将更改为 `true`。在本例中,滑动事件监听器也会替换点击事件监听器,因为我们在 `data-state-swipe` 中传递了 `true`
<div data-state="aria-expanded" data-state-element="js-elem" data-state-swipe="left, true" data-state-behaviour="add">
现在让我们进行更改。
实施
滑动以与管理点击和键盘输入相同的方式管理 - 通过事件监听器。为了让文章专注于我们的可重用函数,我将使用一个名为 `swipeDetect()` 的辅助函数来处理精确滑动检测所需的所有计算。但是,您可以随意使用您自己的首选方法来检测滑动方向以代替它。
我们在我们的可重用函数中构建滑动作为另一种触发函数逻辑的方式,因此,将它与 `initDataState()` 中的点击和键盘事件监听器放在一起,然后在满足我们对所需滑动方向的要求后触发 `processChange()` 是有道理的。
但是,我们还必须考虑 `data-state-swipe` 中传递的行为标志,该标志决定滑动是否应该替换点击。让我们重构 `initDataState()` 以添加一些脚手架来正确支持所有这些
// Init function
initDataState = function(elem){
// Detect data-swipe attribute before we do anything, as its optional
// If not present, assign click event like before
if(elem.getAttribute("data-state-swipe")){
// Grab swipe specific data from data-state-swipe
var elemSwipe = elem.getAttribute("data-state-swipe"),
elemSwipe = elemSwipe.split(", "),
swipeDirection = elemSwipe[0],
elemSwipeBool = elemSwipe[1],
currentElem = elem;
// If the behaviour flag is set to "false", or not set at all, then assign our click event
if(elemSwipeBool === "false" || !elemSwipeBool) {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
swipeDetect(elem, function(swipedir){
if(swipedir === swipeDirection) {
// Run state function
processChange(currentElem);
}
})
}
else {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
};
这些对 `initDataState` 的修改现在使其具有 3 种不同的结果
- 如果触发元素上有一个 `data-state-swipe` 属性,并且其行为布尔值设置为 `true`,则只分配滑动和键盘事件。
- 如果触发元素上有一个 `data-state-swipe` 属性,但其行为布尔值设置为 `false`,则分配滑动、点击和键盘事件。
- 如果触发元素上**没有** `data-state-swipe` 属性,则只分配点击和键盘事件监听器。
示例
以下是在实践中使用新滑动功能的非常简单的示例。点击按钮切换菜单,然后在触摸设备(或您首选的浏览器检查器)上向右滑动菜单将其关闭。很简单。
查看 Pen #9) Adding swipe support to our reusable function by Luke Harrison (@lukedidit) on CodePen.
函数细化
最后,我们将研究如何改进我们的可重用函数,使其更有效率、更易于使用。
定位触发元素
假设我有一个名为 `c-btn` 的元素,单击它需要在自身上切换 `aria-pressed`。按照我们当前的可重用函数,HTML 会如下所示
<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" aria-pressed="false">
这里的问题是,点击时,`aria-pressed` 会在所有 `c-btn` 实例上被切换,这不是我们想要的行为。
这就是创建 `data-state-scope` 来解决的问题。通过将我们的 `data-state` 实例限定到最近的 `c-btn`(在本例中将是它自身),我们创建了想要切换行为。
<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" data-class-scope="c-btn" aria-pressed="false">
虽然上面的代码片段工作正常,但让所有这些属性都引用相同的 `c-btn` 元素有点令人不快。理想情况下,如果未定义 `data-state-element` 和 `data-state-scope`,则函数应该默认为触发它的元素。这将允许轻松定位我们的触发元素。像这样
<button class="c-btn" data-state="aria-pressed" aria-pressed="false">
实施
data-scope-element
目前是一个必需属性。如果它不存在,函数将无法分配任何事件监听器。这是因为在我们当前的可重用函数中,文档的初始扫描正在寻找同时具有 `data-scope` **和** `data-scope-element` 属性的元素
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state][data-state-element]");
我们需要调整一下,以便我们只寻找具有 `data-state` 的元素,因为 `data-state-element` 将很快降级为可选属性。
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state]");
此外,我们需要在 `processChange()` 中添加一个 if 语句,它将围绕 `data-state-element` 值的检索进行包装,因为如果它不存在,函数在尝试对不存在的元素调用 `getAttribute()` 时将抛出错误。
// Grab data-state-element list and convert to array
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
接下来,让我们实现将 `data-state-element` 和 `data-state-scope` 默认为触发元素(如果它们没有明确定义)的逻辑。我们可以基于我们之前对 `processChange()` 的修改,并向 `data-state-element` 检查添加一个 `else` 块来手动声明我们的目标元素和范围。
// Grab data-state-element list and convert to array
// If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
else {
var dataStateElement = [];
dataStateElement.push(elem.classList[0]);
if(!dataStateScope) {
var dataStateScope = dataStateElement;
}
}
使 `data-state-element` 不再必需的另一个结果是,在 `processChange()` 中,它的长度在 `for` 循环中使用,以确保在 `data-state-element` 中定义的所有元素都收到其状态更改。这是当前的循环
// Loop through all our dataStateElement items
for(var b = 0; b < dataStateElement.length; b++) {
[...]
}
值得庆幸的是,我们只需要将现在可选的 `data-state-element` 属性替换为我们仍然需要的 `data-state` 元素作为此循环的基准。
// Loop through all our dataStateElement items
for(var b = 0; b < dataState.length; b++) {
[...]
}
这是因为,在每个 `data-state` 属性传递多个值的情况(例如:`<div data-state="is-active, is-disabled" data-state-element="my-elem, my-elem-2" data-state-behaviour="add, remove">`)中,从每个值派生的数组的长度总是会匹配,因此我们总是在 `for` 块中获得相同数量的循环。
简化重复的值
我们可以做出的另一个改进与在单个 `data-state` 使用中分配类似类型的逻辑有关。考虑下面的例子
<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn, c-btn, c-btn" data-state-behaviour="remove, remove, remove" data-state-scope="o-mycomponent, o-mycomponent, o-mycomponent">
虽然这是我们可重用函数的合法使用方式,但您会注意到,我们在许多 `data-state` 属性中有很多重复的值。理想情况下,如果我们想多次分配类似类型的逻辑,我们应该能够只编写一次值,让函数将其解释为重复值。
例如,下面的 HTML 代码片段应该执行与上面的代码片段相同的操作。
<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn" data-state-behaviour="remove" data-state-scope="o-mycomponent">
以下是应该被视为有效 `data-state` 使用的另一个示例
<a data-state="aria-expanded" data-state-element="c-menu, c-other-menu, c-final-menu" data-state-behaviour="remove, add">
实施
我们需要考虑的第一件事是 `processChange()` 中的 `for` 循环,我们在上一节中最后修改了它。因为它使用 `data-state` 的长度作为循环量的基础,所以实施这些更改将暴露一个错误,即当我们对多个元素应用一个类时会发生这种错误。
考虑以下内容
<a data-state="my-state" data-state-element="c-btn, c-btn-2" data-state-behaviour="remove" data-state-scope="o-mycomponent">
这里会发生什么,因为 `data-state` 只有一个值,所以 `processChange()` 中的 `for` 循环只会循环一次,这意味着我们对 `c-btn-2` 的预期逻辑永远不会被分配。
为了解决这个问题,我们需要比较 `data-state` 和 `data-state-element`。具有最多值的任何一个将成为我们循环的基础。像这样
// Find out which has the biggest length between states and elements and use that length as loop number
// This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
var dataLength = Math.max(dataStateElement.length, dataState.length);
// Loop
for(var b = 0; b < dataLength; b++) {
[...]
}
至于其余的实现,现在只需要在 `for` 循环中为每个属性添加逻辑,该逻辑表示“如果找不到匹配的值,请使用最后一个有效的值”。
让我们以 `data-state` 值为例。当前,`for` 循环中获取状态值的代码如下所示
// Grab state we will add
var elemState = dataState[b];
现在的问题是,如果我们有 3 个 `data-state-element` 值,但只有一个 `data-state` 值,那么在循环 2 和 3 中,`elemState` 将为 `undefined`。
我们只需要在有值要赋予 elemState
时重新定义它。像这样
// Grab state we will add
// If one isn't found, keep last valid one
if(dataState[b] !== undefined) {
var elemState = dataState[b];
}
这将确保 elemState
始终具有值,包括在无法初始找到值时继承任何先前值。
示例
以下是一个展示我们所有函数改进的最终示例
查看 CodePen 上的 Luke Harrison ( @lukedidit ) 创建的 #10) 允许更轻松地定位自身并进行一般改进 。
结束
在这篇文章中,我们介绍了如何基于在 第 1 部分 中创建的可重用函数构建,使其更易于访问和使用。
此外,我们还为触发元素添加了滑动支持,并确保在 DOM 初次渲染后添加的任何 data-state
元素不再被忽略。
与以前一样,欢迎任何评论或建设性反馈。我将向您提供我们在过去两篇文章中开发的完整可重用函数
(function(){
// SWIPE DETECT HELPER
//----------------------------------------------
var swipeDetect = function(el, callback){
var touchsurface = el,
swipedir,
startX,
startY,
dist,
distX,
distY,
threshold = 100, //required min distance traveled to be considered swipe
restraint = 100, // maximum distance allowed at the same time in perpendicular direction
allowedTime = 300, // maximum time allowed to travel that distance
elapsedTime,
startTime,
eventObj,
handleswipe = callback || function(swipedir, eventObj){}
touchsurface.addEventListener('touchstart', function(e){
var touchobj = e.changedTouches[0]
swipedir = 'none'
dist = 0
startX = touchobj.pageX
startY = touchobj.pageY
startTime = new Date().getTime() // record time when finger first makes contact with surface
eventObj = e;
}, false)
touchsurface.addEventListener('touchend', function(e){
var touchobj = e.changedTouches[0]
distX = touchobj.pageX - startX // get horizontal dist traveled by finger while in contact with surface
distY = touchobj.pageY - startY // get vertical dist traveled by finger while in contact with surface
elapsedTime = new Date().getTime() - startTime // get time elapsed
if (elapsedTime <= allowedTime){ // first condition for awipe met
if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint){ // 2nd condition for horizontal swipe met
swipedir = (distX < 0)? 'left' : 'right' // if dist traveled is negative, it indicates left swipe
}
else if (Math.abs(distY) >= threshold && Math.abs(distX) <= restraint){ // 2nd condition for vertical swipe met
swipedir = (distY < 0)? 'up' : 'down' // if dist traveled is negative, it indicates up swipe
}
}
handleswipe(swipedir, eventObj)
}, false)
}
// CLOSEST PARENT HELPER FUNCTION
//----------------------------------------------
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// REUSABLE FUNCTION
//----------------------------------------------
// Change function
processChange = function(elem){
// Grab data-state list and convert to array
var dataState = elem.getAttribute("data-state");
dataState = dataState.split(", ");
// Grab data-state-behaviour list if present and convert to array
if(elem.getAttribute("data-state-behaviour")) {
var dataStateBehaviour = elem.getAttribute("data-state-behaviour");
dataStateBehaviour = dataStateBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(elem.getAttribute("data-state-scope")) {
var dataStateScope = elem.getAttribute("data-state-scope");
dataStateScope = dataStateScope.split(", ");
}
// Grab data-state-element list and convert to array
// If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
if(elem.getAttribute("data-state-element")) {
var dataStateElement = elem.getAttribute("data-state-element");
dataStateElement = dataStateElement.split(", ");
}
else {
var dataStateElement = [];
dataStateElement.push(elem.classList[0]);
if(!dataStateScope) {
var dataStateScope = dataStateElement;
}
}
// Find out which has the biggest length between states and elements and use that length as loop number
// This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
var dataLength = Math.max(dataStateElement.length, dataState.length);
// Loop
for(var b = 0; b < dataLength; b++) {
// If a data-state-element value isn't found, use last valid one
if(dataStateElement[b] !== undefined) {
var dataStateElementValue = dataStateElement[b];
}
// If scope isn't found, use last valid one
if(dataStateScope && dataStateScope[b] !== undefined) {
var cachedScope = dataStateScope[b];
}
else if(cachedScope) {
dataStateScope[b] = cachedScope;
}
// Grab elem references, apply scope if found
if(dataStateScope && dataStateScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(elem, dataStateScope[b]);
// Grab all matching child elements of parent
var elemRef = elemParent.querySelectorAll("." + dataStateElementValue);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-state-element and fits within scope
if(elemParent.classList.contains(dataStateElementValue)) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataStateElementValue);
}
// Grab state we will add
// If one isn't found, keep last valid one
if(dataState[b] !== undefined) {
var elemState = dataState[b];
}
// Grab behaviour if any exists
// If one isn't found, keep last valid one
if(dataStateBehaviour) {
if(dataStateBehaviour[b] !== undefined) {
var elemBehaviour = dataStateBehaviour[b];
}
}
// Do
for(var c = 0; c < elemRef.length; c++) {
// Find out if we're manipulating aria-attributes or classes
var toggleAttr;
if(elemRef[c].getAttribute(elemState)) {
toggleAttr = true;
}
else {
toggleAttr = false;
}
if(elemBehaviour === "add") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, true);
}
else {
elemRef[c].classList.add(elemState);
}
}
else if(elemBehaviour === "remove") {
if(toggleAttr) {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].classList.remove(elemState);
}
}
else {
if(toggleAttr) {
if(elemRef[c].getAttribute(elemState) === "true") {
elemRef[c].setAttribute(elemState, false);
}
else {
elemRef[c].setAttribute(elemState, true);
}
}
else {
elemRef[c].classList.toggle(elemState);
}
}
}
}
},
// Init function
initDataState = function(elem){
// Detect data-swipe attribute before we do anything, as its optional
// If not present, assign click event like before
if(elem.getAttribute("data-state-swipe")){
// Grab swipe specific data from data-state-swipe
var elemSwipe = elem.getAttribute("data-state-swipe"),
elemSwipe = elemSwipe.split(", "),
direction = elemSwipe[0],
elemSwipeBool = elemSwipe[1],
currentElem = elem;
// If the behaviour flag is set to "false", or not set at all, then assign our click event
if(elemSwipeBool === "false" || !elemSwipeBool) {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
swipeDetect(elem, function(swipedir){
if(swipedir === direction) {
// Run state function
processChange(currentElem);
}
})
}
else {
// Assign click event
elem.addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
});
}
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
if(e.which === 13) {
// Prevent default action of element
e.preventDefault();
// Run state function
processChange(this);
}
});
};
// Run when DOM has finished loading
document.addEventListener("DOMContentLoaded", function() {
// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state]");
// Loop through our matches and add click events
for(var a = 0; a < elems.length; a++){
initDataState(elems[a]);
}
// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var d = 0; d < mutation.addedNodes.length; d++) {
// Check if we're dealing with an element node
if(typeof mutation.addedNodes[d].getAttribute === 'function') {
if(mutation.addedNodes[d].getAttribute("data-state")) {
initDataState(mutation.addedNodes[d]);
}
}
}
});
});
// Define type of change our observer will watch out for
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}());
文章系列
- 原文
- 使用可复用 JavaScript 函数管理 CSS 中的状态(您现在就在这里!)
感谢您发表这篇文章,Luke。我有一个简单的问题。为什么使用 Mutation Observer,而我们可以使用 Event Delegation 呢?可能有一些变化,但我认为如果可能的话,它会更好。
您好,Luke,
不幸的是,我认为您误解了 ARIA 规范中 aria-expanded 属性的预期位置。
在您的手风琴和侧边栏菜单示例中,您分别将 aria-expanded 属性放在手风琴/导航的容器上。该属性应放在显示这些元素的按钮上。
这是一个我创建的关于基本 ARIA 手风琴示例的链接,用来展示如何设置它:https://codepen.io/scottohara/pen/RVLbrp
如果您有任何疑问,我很乐意与您进一步讨论。:)
好主意。我已经相应地更新了每个示例。:)
感谢您发表这篇文章,我喜欢您使用 aria 属性的方式!
我使用 linter 格式化了您的代码,并对其进行了一些整理。这里有一个副本:https://pastebin.com/0eXacNZx
只想说声谢谢。我是一个新手,我写了一些代码来实现这个结果,但它让我很困扰,因为它是如此麻烦,而且在做了所有这些之后,它不能重复使用。所以,我真的很感谢这一点,因为我感同身受。对第 1 部分的评论提出了一些额外的担忧,这些担忧成为进一步阅读的起点,但您在第 2 部分中很好地解决了这些问题。
我计划实施这些大部分内容,长期的目标是实现 Vue(只模模糊糊地知道 js 框架解决了其中的一些需求)。
代码和解释的交替非常清晰,易于理解。
感谢您的赞赏。我认为,针对状态管理问题的工作,自定义的、更小的解决方案,确实能让你更欣赏 Vue 等完整框架带来的价值。祝一切顺利!
您好,Luke,
感谢您发表这篇文章,如果使用 Angular JS 会更有趣。
祝您一切顺利。
精彩的文章,Luke。
不过我有一个关于提高可访问性的建议。
当手风琴折叠时,任何包含的链接都需要从密钥链中删除。
这是一个肮脏的 CSS 解决方案
我有点不太舒服,因为 “button” div 缺少
role="button"
,而且它不能仅仅以泛型方式添加。它在 span 或 div 上是可以的,但如果应用于标题标签,则会很糟糕,因为语义冲突。很喜欢添加的触控和 Mutation Observer,真是太棒了,老兄!
感谢您的建议。我相信在这些示例中还有很多改进的空间。毕竟,它们只是为了演示在 data-state 函数中切换类和 ARIA 属性。:)