mcpField
L
liquid-theme-a11y

liquid-theme-a11y

github.com/shopify设计与 UI36235 次安装32 周前更新
代码仓库

关于 liquid-theme-a11y

为 Shopify Liquid 主题开发提供无障碍实现参考,涵盖语义标记、键盘与屏幕阅读器支持、焦点管理及减少动态效果等常见组件实践。

主要功能

  1. 01语义化 HTML 与 ARIA 指引
  2. 02键盘和屏幕阅读器模式
  3. 03对话框与抽屉焦点管理
  4. 04移动端与触控无障碍
  5. 05动态效果与对比度指引

适用场景

  1. 01检查 Shopify 主题组件
  2. 02实现无障碍表单与筛选器
  3. 03构建键盘可操作的对话框或购物车抽屉

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/shopify/liquid-skills --skill liquid-theme-a11y

安装后在 Claude Code 中输入/liquid-theme-a11y确认已加载。

附带文件references/component-patterns.mdreferences/focus-and-keyboard.md

详细介绍

Shopify Liquid 主题的无障碍设计

核心原则

每个交互组件都必须支持仅使用键盘操作、屏幕阅读器和减少动态效果偏好。优先使用语义化 HTML——仅当原生语义不足时才添加 ARIA。

决策表:使用哪种模式?

组件HTML 元素ARIA 模式参考
可展开内容<details>/<summary>无需折叠面板
模态框/对话框<dialog>aria-modal="true"模态框
工具提示/弹出框[popover] 属性role="tooltip" 作为回退工具提示
下拉菜单<nav> + <ul>在触发器上使用 aria-expanded导航
选项卡界面<div>role="tablist/tab/tabpanel"选项卡
轮播/滑块<div>role="region" + aria-roledescription轮播
产品卡片<article>aria-labelledby产品卡片
表单<form>aria-invalid、aria-describedby表单
购物车抽屉<dialog>焦点陷阱购物车抽屉
价格显示<span>使用 aria-label 提供上下文价格
筛选器<form> + <fieldset>对披露控件使用 aria-expanded筛选器

页面结构

地标
<body>
  <a href="#main-content" class="skip-link">{{ 'accessibility.skip_to_content' | t }}</a>
  <header role="banner">
    <nav aria-label="{{ 'accessibility.main_navigation' | t }}">...</nav>
  </header>
  <main id="main-content">
    
  </main>
  <footer role="contentinfo">
    <nav aria-label="{{ 'accessibility.footer_navigation' | t }}">...</nav>
  </footer>
</body>
  • 每页仅使用一个 <header>、<main>、<footer>
  • 多个 <nav> 元素必须具有不同的 aria-label
  • 所有内容都必须位于地标内
跳过链接
.skip-link {
  position: absolute;
  inset-inline-start: -999px;
  z-index: 999;
}
.skip-link:focus {
  position: fixed;
  inset-block-start: 0;
  inset-inline-start: 0;
  padding: 1rem;
  background: var(--color-background);
  color: var(--color-foreground);
}
标题
  • 每页使用一个 <h1>,级别不得跳跃(h1 → h3)
  • 使用真正的标题元素,而不是经过样式设置的 div
  • 模板:<h1> 通常是页面/产品标题

焦点管理

焦点指示器
/* All interactive elements */
:focus-visible {
  outline: 2px solid rgb(var(--color-focus));
  outline-offset: 2px;
}

/* High contrast mode */
@media (forced-colors: active) {
  :focus-visible {
    outline: 3px solid LinkText;
  }
}
  • 焦点指示器的对比度至少为 3:1
  • 使用 :focus-visible(而非 :focus),避免点击时显示
  • 除非提供可见的替代样式,否则绝不使用 outline: none
焦点陷阱(模态框/抽屉)
  • 将焦点限制在模态框、抽屉和对话框内
  • 关闭时将焦点返回触发元素
  • 打开时将焦点放在第一个可聚焦元素上
  • 查询所有可聚焦元素:a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])

完整的 FocusTrap 实现请参见焦点与键盘模式。

组件模式

产品卡片
<article class="product-card" aria-labelledby="ProductTitle-{{ product.id }}">
  <a href="{{ product.url }}" class="product-card__link" aria-labelledby="ProductTitle-{{ product.id }}">
    <img
      src="{{ product.featured_image | image_url: width: 400 }}"
      alt="{{ product.featured_image.alt | escape }}"
      loading="lazy"
      width="{{ product.featured_image.width }}"
      height="{{ product.featured_image.height }}"
    >
  </a>
  <h3 id="ProductTitle-{{ product.id }}">
    <a href="{{ product.url }}">{{ product.title }}</a>
  </h3>
  <div class="product-card__price" aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
    {{ product.price | money }}
  </div>
  <button
    class="product-card__quick-add"
    tabindex="-1"
    aria-label="{{ 'products.quick_add' | t: title: product.title }}"
  >
    {{ 'products.add_to_cart' | t }}
  </button>
</article>

规则:

  • 每张卡片只有一个 Tab 停靠点(主链接)
  • 仅供鼠标使用的快捷操作(快速添加)设置 tabindex="-1"
  • 在 <article> 上设置 aria-labelledby,指向标题
  • 为图片提供描述性替代文本;装饰性图片使用空 alt=""
轮播
<div
  role="region"
  aria-roledescription="carousel"
  aria-label="{{ section.settings.heading | escape }}"
>
  <div class="carousel__controls">
    <button
      aria-label="{{ 'accessibility.previous_slide' | t }}"
      aria-controls="CarouselSlides-{{ section.id }}"
    >{% render 'icon-chevron-left' %}</button>
    <button
      aria-label="{{ 'accessibility.next_slide' | t }}"
      aria-controls="CarouselSlides-{{ section.id }}"
    >{% render 'icon-chevron-right' %}</button>
    <button
      aria-label="{{ 'accessibility.pause_slideshow' | t }}"
      aria-pressed="false"
    >{% render 'icon-pause' %}</button>
  </div>

  <div id="CarouselSlides-{{ section.id }}" aria-live="polite">
    {% for slide in section.blocks %}
      <div
        role="group"
        aria-roledescription="slide"
        aria-label="{{ 'accessibility.slide_n_of_total' | t: n: forloop.index, total: forloop.length }}"
        {% unless forloop.first %}aria-hidden="true"{% endunless %}
      >
        {{ slide.settings.content }}
      </div>
    {% endfor %}
  </div>
</div>

规则:

  • 自动轮播间隔至少为 5 秒,并在悬停/聚焦时暂停
  • 自动轮播必须提供播放/暂停按钮
  • 在幻灯片容器上设置 aria-live="polite"(自动轮播期间设为 "off")
  • 在非活动幻灯片上设置 aria-hidden="true"
  • 每张幻灯片使用 role="group" + aria-roledescription="slide"
模态框
<dialog
  id="Modal-{{ section.id }}"
  aria-labelledby="ModalTitle-{{ section.id }}"
  aria-modal="true"
>
  <div class="modal__header">
    <h2 id="ModalTitle-{{ section.id }}">{{ title }}</h2>
    <button
      type="button"
      aria-label="{{ 'accessibility.close' | t }}"
      on:click="/closeModal"
    >{% render 'icon-close' %}</button>
  </div>
  <div class="modal__content">
    
  </div>
</dialog>

规则:

  • 可行时,优先使用原生 <dialog> 元素实现模态界面。showModal() 提供原生模态行为、按 Escape 关闭和背景遮罩处理;但当原生 <dialog> 不适用时,role="dialog" 仍是有效的回退方案。
  • 使用 aria-labelledby 指向标题(不要使用带字符串的 aria-label——标题变更时,aria-labelledby 会保持同步)
  • 按 Escape 键关闭(使用原生 <dialog> 时支持)
  • 打开时将焦点放在第一个交互元素上
  • 关闭时将焦点返回触发元素
购物车抽屉

与模态框模式相同,但还需:

  • 为购物车数量更新添加实时区域:<span aria-live="polite" aria-atomic="true">
  • 提供清晰的“移除商品”按钮,并设置 aria-label="{{ 'cart.remove_item' | t: title: item.title }}"
  • 为数量输入框关联标签
表单
<form action="{{ routes.cart_url }}" method="post">
  <div class="form__field">
    <label for="Email-{{ section.id }}">{{ 'forms.email' | t }}</label>
    <input
      type="email"
      id="Email-{{ section.id }}"
      name="email"
      required
      aria-required="true"
      autocomplete="email"
      aria-describedby="EmailError-{{ section.id }}"
    >
    <p
      id="EmailError-{{ section.id }}"
      class="form__error"
      role="alert"
      hidden
    >{{ 'forms.email_required' | t }}</p>
  </div>
</form>

规则:

  • 每个输入框都有可见的 <label>,且 for/id 相匹配
  • 单选框/复选框组使用 <fieldset>/<legend>
  • 错误消息:使用 role="alert",并通过 aria-describedby 关联输入框
  • 无效输入框上设置 aria-invalid="true"
  • 常见字段设置 autocomplete 属性
  • 必填字段:设置 required 和 aria-required="true",并添加视觉标识
商品筛选器
<form class="facets">
  <div class="facets__group">
    <button
      type="button"
      aria-expanded="false"
      aria-controls="FilterColor-{{ section.id }}"
    >{{ 'filters.color' | t }}</button>
    <fieldset id="FilterColor-{{ section.id }}" hidden>
      <legend class="visually-hidden">{{ 'filters.filter_by_color' | t }}</legend>
      {% for color in colors %}
        <label>
          <input type="checkbox" name="filter.color" value="{{ color }}">
          {{ color }}
        </label>
      {% endfor %}
    </fieldset>
  </div>
  <div aria-live="polite" aria-atomic="true">
    {{ 'filters.results_count' | t: count: results.size }}
  </div>
</form>
价格显示
{% if product.compare_at_price > product.price %}
  <div class="price" aria-label="{{ 'products.sale_price_label' | t: sale_price: product.price | money, original_price: product.compare_at_price | money }}">
    <s aria-hidden="true">{{ product.compare_at_price | money }}</s>
    <span>{{ product.price | money }}</span>
  </div>
{% else %}
  <div class="price" aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
    {{ product.price | money }}
  </div>
{% endif %}
  • 特价和原价的显示路径都使用 aria-label——屏幕阅读器需要了解任何价格显示的上下文
  • 视觉删除线设置 aria-hidden="true",避免重复朗读
折叠面板
<details>
  <summary>{{ block.settings.heading }}</summary>
  <div class="accordion__content">
    {{ block.settings.content }}
  </div>
</details>

原生 <details>/<summary> 会自动提供键盘和屏幕阅读器支持。

标签页
<div role="tablist" aria-label="{{ 'accessibility.product_tabs' | t }}">
  {% for tab in tabs %}
    <button
      role="tab"
      id="Tab-{{ tab.id }}"
      aria-selected="{% if forloop.first %}true{% else %}false{% endif %}"
      aria-controls="Panel-{{ tab.id }}"
      tabindex="{% if forloop.first %}0{% else %}-1{% endif %}"
    >{{ tab.title }}</button>
  {% endfor %}
</div>
{% for tab in tabs %}
  <div
    role="tabpanel"
    id="Panel-{{ tab.id }}"
    aria-labelledby="Tab-{{ tab.id }}"
    {% unless forloop.first %}hidden{% endunless %}
    tabindex="0"
  >{{ tab.content }}</div>
{% endfor %}
  • 使用方向键在标签页之间切换(左/右)
  • 只有当前激活的标签页设置 tabindex="0",其他标签页设置为 -1
下拉导航
<nav aria-label="{{ 'accessibility.main_navigation' | t }}">
  <ul role="list">
    {% for link in linklists.main-menu.links %}
      <li>
        {% if link.links.size > 0 %}
          <button aria-expanded="false" aria-controls="Submenu-{{ forloop.index }}">
            {{ link.title }}
          </button>
          <ul id="Submenu-{{ forloop.index }}" hidden role="list">
            {% for child in link.links %}
              <li><a href="{{ child.url }}">{{ child.title }}</a></li>
            {% endfor %}
          </ul>
        {% else %}
          <a href="{{ link.url }}">{{ link.title }}</a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>
</nav>
工具提示
<button aria-describedby="Tooltip-{{ block.id }}">
  {{ 'labels.info' | t }}
</button>
<div id="Tooltip-{{ block.id }}" role="tooltip" popover>
  {{ block.settings.tooltip_text }}
</div>

移动端无障碍

  • 触控目标: 最小尺寸为 44x44px,目标之间间隔 8px
  • 不锁定方向: 不得限制为纵向或横向
  • 不使用仅悬停显示的内容: 所有内容都可通过点击访问
  • 移动端视口单位使用 dvh 而不是 vh

动画与动态效果

/* Always provide reduced motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
  • 闪烁频率不得超过每秒 3 次
  • 自动播放的动画需要提供暂停/停止控件
  • 只使用有意义的动画——不要为了装饰而添加动画

视觉隐藏实用工具

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

用于仅供屏幕阅读器使用的内容,例如标签和描述。

渐进增强

交互组件应尽可能在没有 JavaScript 的情况下正常工作。对于依赖 JS 的控件,请提供 <noscript> 回退方案:

{%- comment -%} Variant picker with noscript fallback {%- endcomment -%}
<variant-picker>
  
</variant-picker>
<noscript>
  <select name="id" aria-label="{{ 'products.select_variant' | t }}">
    {% for variant in product.variants %}
      <option value="{{ variant.id }}" {% unless variant.available %}disabled{% endunless %}>
        {{ variant.title }} - {{ variant.price | money }}
      </option>
    {% endfor %}
  </select>
</noscript>
动态更新的实时区域

当选项(款式、筛选器、购物车)发生变化时,应向屏幕阅读器播报变化:

<div aria-live="polite" aria-atomic="true" class="visually-hidden">
  {{ 'products.variant_selected' | t: variant: selected_variant.title }}
</div>

在 JS 中使用先清除再设置的模式,确保可靠触发播报:

announce(message) {
  this.liveRegion.textContent = '';
  requestAnimationFrame(() => {
    this.liveRegion.textContent = message;
  });
}

颜色对比度

元素最低比例
正文(<18px / <14px 粗体)4.5:1
大号文本(≥18px / ≥14px 粗体)3:1
UI 组件和图形3:1
焦点指示器3:1

切勿仅依靠颜色传达信息——应始终搭配文本、图标或图案。

参考资料

评论

支持 Markdown