关于 liquid-theme-a11y
为 Shopify Liquid 主题开发提供无障碍实现参考,涵盖语义标记、键盘与屏幕阅读器支持、焦点管理及减少动态效果等常见组件实践。
主要功能
- 01语义化 HTML 与 ARIA 指引
- 02键盘和屏幕阅读器模式
- 03对话框与抽屉焦点管理
- 04移动端与触控无障碍
- 05动态效果与对比度指引
适用场景
- 01检查 Shopify 主题组件
- 02实现无障碍表单与筛选器
- 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