使用 HTML 复选框和 CSS 制作完整的状态机

Avatar of Ryan Bethel
Ryan Bethel

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

状态机通常在 Web 上使用 JavaScript 表示,并且通常通过流行的 XState 库实现。但状态机的概念适应于几乎任何语言,包括令人惊讶的 HTML 和 CSS。在本文中,我们将尝试实现这一点。我最近构建了一个网站,其中包含“无客户端 JavaScript”约束,我需要一个特定的独特交互功能。

所有这些的关键是使用 <form><input type="radio"> 元素来保存状态。该状态通过另一个单选 <input> 或重置 <button> 切换或重置,该按钮可以位于页面上的任何位置,因为它连接到相同的 <form> 标签。我将这种组合称为单选重置控制器,它在文章结尾有更详细的解释。您可以使用其他表单/输入对添加更复杂的状态。

它有点像 复选框技巧,最终 CSS 中的 :checked 选择器将完成 UI 工作,但逻辑上更高级。我最终在这篇文章中使用了模板语言 (Nunjucks) 以使其可管理和可配置。

交通灯状态机

任何状态机解释都必须包含强制性的交通灯示例。下面是一个使用 HTML 和 CSS 中的状态机的交通灯。单击“下一步”将推进状态。此 Pen 中的代码是 从状态机模板处理后的代码,以适应 Pen。我们稍后将以更易读的方式深入了解代码。

隐藏/显示表格信息

交通灯不是最实用的日常 UI。我们改用 <table> 如何?

有两个状态 (A 和 B),它们从设计中的两个不同位置更改,这些更改会影响整个 UI 的更改。这是可能的,因为空的 <form> 元素和保存状态的 <input> 元素位于标记的最顶部,因此可以通过通用兄弟选择器推断其状态,并且可以使用后代选择器访问 UI 的其余部分。这里 UI 和标记之间存在松散耦合,这意味着我们可以从页面上的任何位置更改页面上几乎任何东西的状态。

通用四状态组件

通用四状态有限状态机

目标是创建一个通用组件来控制页面的所需状态。“页面状态”这里指的是页面所需的狀態,而“机器状态”指的是控制器本身的内部状态。上图显示了这个通用状态机,它具有四个状态(A、B、C 和 D)。此控制器的完整状态机如下所示。它是使用三个单选重置控制器位构建的。将三个这样的控制器组合在一起形成一个具有八个内部机器状态的状态机(三个独立的单选按钮,它们是开或关)。

控制器内部状态图

“机器状态”被写成三个单选按钮的组合(例如 M001 或 M101)。要从初始状态 M111 转移到 M011,通过单击同一组中的另一个单选 <input> 来取消设置该位的单选按钮。要转换回来,单击与该位关联的 <form> 的重置 <button>,这将恢复默认的选中状态。尽管此机器共有八个状态,但只有某些转换是可能的。例如,无法直接从 M111 转移到 M100,因为它需要翻转两个位。但是,如果我们将这八个状态折叠成四个状态,以便每个页面状态共享两个机器状态(例如 A 共享状态 M111 和 M000),那么从任何页面状态到任何其他页面状态都有一个单一转换。

可重用四状态组件

为了实现可重用性,该组件使用 Nunjucks 模板宏构建。这使它可以放置在任何页面中,以添加具有所需有效状态和转换的状态机。有四个必需的子组件

  • 控制器
  • CSS 逻辑
  • 转换控制
  • 状态类

控制器

控制器使用三个空表单标签和三个单选按钮构建。每个单选按钮的 checked 属性默认情况下都是 checked。每个按钮都连接到其中一个表单,它们彼此独立,具有自己的单选按钮组名称。这些输入使用 display: none 隐藏,因为它们不会被直接更改或看到。这三个输入的状态构成机器状态,并且此控制器放置在页面的顶部。

{% macro FSM4S_controller()%}
  <form id="rrc-form-Bx00"></form>
  <form id="rrc-form-B0x0"></form>
  <form id="rrc-form-B00x"></form>
  <input data-rrc="Bx00" form="rrc-form-Bx00" style="display:none" type="radio" name="rrc-Bx00" checked="checked" />
  <input data-rrc="B0x0" form="rrc-form-B0x0" style="display:none" type="radio" name="rrc-B0x0" checked="checked" />
  <input data-rrc="B00x" form="rrc-form-B00x" style="display:none" type="radio" name="rrc-B00x" checked="checked" />
{% endmacro %}

CSS 逻辑

将上面的控制器连接到页面状态的逻辑是用 CSS 编写的。复选框技巧使用类似的技术,通过复选框来控制兄弟或后代元素。这里的区别是,控制状态的按钮没有与它选择的元素紧密耦合。下面的逻辑根据三个控制器单选按钮中的每一个的“checked”状态进行选择,以及任何具有类 .M000 的后代元素。此状态机通过设置 display: none !important 来隐藏任何具有 .M000 类的元素。!important 在这里不是逻辑的关键部分,可以删除;它只是优先隐藏,以免被其他 CSS 覆盖。

{%macro FSM4S_css()%}
<style>
  /* Hide M000 (A1) */
  input[data-rrc="Bx00"]:not(:checked)~input[data-rrc="B0x0"]:not(:checked)~input[data-rrc="B00x"]:not(:checked)~* .M000  {
    display: none !important;
  }

  /* one section for each of 8 Machine States */

</style>
{%endmacro%}

转换控制

更改页面状态需要用户单击或按键。要更改机器状态的单个位,用户单击连接到控制器中某个位的相同表单和单选按钮组的单选按钮。要重置它,用户单击连接到同一单选按钮的表单的重置按钮。单选按钮或重置按钮仅在它们处于哪个状态时才会显示。在 HTML 中添加了对任何有效转换的转换宏。页面上可以有多个转换。所有对当前非活动状态的转换都将隐藏。

{%macro AtoB(text="B",class="", classBtn="",classLbl="",classInp="")%}
  <label class=" {{class}} {{classLbl}} {{showM111_A()}} "><input class=" {{classInp}} " form="rrc-form-Bx00" type="radio" name="rrc-Bx00" />{{text}}</label>
  <button class=" {{class}} {{classBtn}} {{showM000_A1()}} " type="reset" form="rrc-form-Bx00">{{text}}</button>
{%endmacro%}

状态类

上面的三个组件就足够了。任何依赖于状态的元素都应应用这些类以在其他状态下隐藏它。这会变得很乱。以下宏用于简化此过程。如果给定元素仅在状态 A 中显示,则 {{showA()}} 宏会添加要隐藏的状态。

{%macro showA() %}
  M001 M010 M100 M101 M110 M011
{%endmacro%}

将所有内容整合在一起

交通灯示例的标记如下所示。模板宏在文件的第一行导入。CSS 逻辑被添加到头部,控制器位于主体的顶部。状态类位于 .traffic-light 元素的每个灯上。点亮的信号有一个 {{showA()}} 宏,而信号的“关闭”版本具有 .M000.M111 类的机器状态,以在状态 A 中隐藏它。状态转换按钮位于页面底部。

{% import "rrc.njk" as rrc %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Traffic Light State Machine Example</title>
  <link rel="stylesheet" href="styles/index.processed.css">
  {{rrc.FSM4S_css()}}
</head>
<body>
  {{rrc.FSM4S_controller()}}
  <div>
    <div class="traffic-light">
      <div class="{{rrc.showA()}} light red-light on"></div>
      <div class="M111 M000 light red-light off"></div>
      <div class="{{rrc.showB()}} light yellow-light on"></div>
      <div class="M100 M011 light yellow-light off"></div>
      <div class="{{rrc.showC()}} light green-light on"></div>
      <div class="M010 M101 light green-light off"></div>
    </div>
    <div>
      <div class="next-state">
        {{rrc.AtoC(text="NEXT", classInp="control-input",
          classLbl="control-label",classBtn="control-button")}}
        {{rrc.CtoB(text="NEXT", classInp="control-input",
          classLbl="control-label",classBtn="control-button")}}
        {{rrc.BtoA(text="NEXT", classInp="control-input",
          classLbl="control-label",classBtn="control-button")}}
      </div>
    </div>
  </div>
</body>
</html>

扩展到更多状态

此处的状态机组件最多包含四个状态,这足以满足许多用例,尤其是因为可以在一个页面上使用多个独立的状态机。

也就是说,此技术可以用于构建具有四个以上状态的状态机。下表显示了通过添加更多位可以构建多少个页面状态。请注意,偶数位的位不能有效地折叠,这就是为什么三位和四位都限制为四个页面状态的原因。

位 (rrcs)机器状态页面状态
122
242
384
4164
5326

无线电复位控制器详细信息

能够在页面上的任何位置显示、隐藏或控制 HTML 元素而无需使用 JavaScript 的技巧,我称之为无线电复位控制器。使用三个标签和一行 CSS,控制按钮和受控元素可以放置在这个控制器的后面。受控方使用一个默认情况下为checked的隐藏无线电按钮。该无线电按钮通过 ID 连接到一个空的<form>元素。该表单具有一个type="reset"按钮和另一个无线电输入,它们共同构成控制器。

<!-- RRC Controller -->
<form id="rrc-form"></form>
<label>
  Show
  <input form="rrc-form" type="radio" name="rrc-group" />
</label>
<button type="reset" form="rrc-form">Hide</button>

<!-- Controlled by RRC -->
<input form="rrc-form" class="hidden" type="radio" name="rrc-group" checked />
<div class="controlled-rrc">Controlled from anywhere</div>

这显示了一个最小的实现。隐藏的无线电按钮和它控制的div需要是兄弟元素,但该输入是隐藏的,用户不需要直接与它进行交互。它由默认的checked值设置,由另一个无线电按钮清除,并由表单复位按钮重置。

input[name='rrc-group']:checked + .controlled-rrc {
  display: none;
}
.hidden {
  display: none;
}

只需要两行 CSS 就可以使它正常工作。:checked伪选择器将隐藏的输入连接到它控制的兄弟元素。它添加了无线电输入和复位按钮,可以将它们样式化为单个切换按钮,如以下 Pen 所示

可访问性… 你应该这样做吗?

这种模式有效,但我并不建议在所有情况下都使用它。在大多数情况下,JavaScript 是为网页添加交互性的正确方法。我知道发布这个可能会收到一些来自可访问性和语义标记专家的批评。我不是可访问性专家,实现这种模式可能会导致问题。也可能不会。一个正确标记的按钮,通过其他隐藏的输入来控制页面上的某些内容,可能会正常工作。就像可访问性领域的任何其他事情一样:需要进行测试。

此外,我还没有看到其他人写过如何做到这一点,我认为这些知识是有用的——即使它只适用于罕见或边缘情况。