typo3-accessibility

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

TYPO3 Accessibility (WCAG 2.2 AA)

TYPO3 无障碍适配(WCAG 2.2 AA标准)

Compatibility: TYPO3 v14.x (primary), v13.x, v12.4 LTS (fallbacks noted). All code works on v14. Version-specific fallbacks are marked with
v12/v13:
.
TYPO3 API First: Always use TYPO3's built-in APIs, Fluid ViewHelpers, and core features before adding custom markup. Verify methods exist in your target version.
PHP & JS over TypoScript: This skill provides PHP middleware, Fluid partials, and vanilla JavaScript solutions. TypoScript examples are avoided; use PHP-based approaches.
兼容性: TYPO3 v14.x(主要支持版本)、v13.x、v12.4 LTS(标注了兼容回退方案)。 所有代码均适用于v14版本,针对特定版本的回退方案会标记为
v12/v13:
TYPO3 API优先原则: 在添加自定义标记之前,务必优先使用TYPO3的内置API、Fluid ViewHelper和核心功能。请验证目标版本中是否存在对应方法。
优先使用PHP和JS而非TypoScript: 本方案提供PHP中间件、Fluid局部模板和原生JavaScript解决方案。避免使用TypoScript示例,采用基于PHP的实现方式。

1. Accessibility Checklist (Go-Live Gate)

1. 无障碍检查清单(上线准入标准)

Run through this checklist before every deployment. Mark items as you fix them.
每次部署前请逐一检查以下项,修复后标记完成状态。

Semantic Structure

语义化结构

  • Every page has exactly one
    <h1>
  • Heading hierarchy is sequential (
    h1
    >
    h2
    >
    h3
    , no skips)
  • Landmark elements used:
    <main>
    ,
    <nav>
    ,
    <header>
    ,
    <footer>
    ,
    <aside>
  • Skip-to-content link is the first focusable element
  • <html lang="...">
    matches page language
  • Language changes within content use
    lang
    attribute on containing element
  • 每个页面仅包含一个
    <h1>
    标签
  • 标题层级连续(
    h1
    >
    h2
    >
    h3
    ,禁止跳过层级)
  • 使用地标元素:
    <main>
    <nav>
    <header>
    <footer>
    <aside>
  • 跳转到内容的链接是第一个可获取焦点的元素
  • <html lang="...">
    与页面语言匹配
  • 内容中语言切换的部分,在包含元素上添加
    lang
    属性

Images & Media

图片与媒体

  • All
    <img>
    have
    alt
    attribute (empty
    alt=""
    for decorative)
  • All
    <img>
    have explicit
    width
    and
    height
    (prevents CLS)
  • Complex images (charts, infographics) have long description
  • Videos have captions/subtitles
  • Audio content has transcript
  • No auto-playing media with sound
  • 所有
    <img>
    标签均包含
    alt
    属性(装饰性图片使用空值
    alt=""
  • 所有
    <img>
    标签均显式设置
    width
    height
    (避免布局偏移CLS)
  • 复杂图片(图表、信息图)包含详细描述
  • 视频添加字幕/副标题
  • 音频内容提供文字转录
  • 禁止自动播放带声音的媒体

Color & Contrast

色彩与对比度

  • Text contrast >= 4.5:1 (normal) / 3:1 (large text >= 24px / 18.66px bold)
  • UI components and graphical objects >= 3:1 contrast
  • Information not conveyed by color alone (add icon, text, or pattern)
  • Dark mode (if implemented) maintains contrast ratios
  • 普通文本对比度≥4.5:1,大文本(≥24px或≥18.66px加粗)对比度≥3:1
  • UI组件和图形对象对比度≥3:1
  • 信息传递不依赖单一色彩(需搭配图标、文字或图案)
  • 若实现深色模式,需维持符合标准的对比度

Keyboard & Focus

键盘与焦点

  • All interactive elements reachable via Tab
  • Focus order matches visual order
  • Visible focus indicator on all interactive elements (
    :focus-visible
    )
  • No focus traps (except modals — which must trap correctly)
  • Escape closes modals/overlays and returns focus to trigger
  • Custom widgets have correct keyboard handlers
  • 所有交互元素均可通过Tab键访问
  • 焦点顺序与视觉顺序一致
  • 所有交互元素均有可见的焦点指示器(使用
    :focus-visible
  • 无焦点陷阱(模态框除外,但需正确实现焦点陷阱)
  • 按Escape键可关闭模态框/浮层,并将焦点返回至触发元素
  • 自定义组件具备正确的键盘事件处理逻辑

Forms

表单

  • Every input has a programmatic
    <label>
    (explicit
    for
    /
    id
    or wrapping)
  • Placeholder is not used as sole label
  • Required fields indicated visually and with
    required
    attribute
  • Error messages linked to inputs via
    aria-describedby
    +
    aria-invalid
  • Form groups use
    <fieldset>
    +
    <legend>
  • Correct
    type
    and
    autocomplete
    attributes on inputs
  • 每个输入框均关联程序化的
    <label>
    标签(使用显式
    for
    /
    id
    或包裹方式)
  • 禁止仅使用占位符作为标签
  • 必填字段同时提供视觉标识和
    required
    属性
  • 错误提示通过
    aria-describedby
    aria-invalid
    关联到对应输入框
  • 表单组使用
    <fieldset>
    +
    <legend>
    结构
  • 输入框设置正确的
    type
    autocomplete
    属性

ARIA & Dynamic Content

ARIA与动态内容

  • Icon-only buttons have
    aria-label
  • Decorative icons have
    aria-hidden="true"
  • Dynamic updates use
    aria-live="polite"
    or
    role="status"
  • Error alerts use
    role="alert"
  • Active navigation links have
    aria-current="page"
  • Expandable elements use
    aria-expanded
  • 仅含图标的按钮添加
    aria-label
    属性
  • 装饰性图标设置
    aria-hidden="true"
  • 动态更新内容使用
    aria-live="polite"
    role="status"
  • 错误提示使用
    role="alert"
  • 当前激活的导航链接设置
    aria-current="page"
  • 可展开元素使用
    aria-expanded
    属性

Motion & Interaction

动效与交互

  • prefers-reduced-motion
    respected (CSS and JS)
  • No content flashes more than 3 times per second
  • Touch targets >= 44x44 CSS pixels
  • user-scalable=no
    is NOT set in viewport meta
  • 尊重
    prefers-reduced-motion
    设置(CSS和JS层面均需适配)
  • 内容闪烁频率不超过每秒3次
  • 触摸目标尺寸≥44x44 CSS像素
  • 禁止在视口meta标签中设置
    user-scalable=no

TYPO3-Specific

TYPO3专属检查项

  • Backend: alt text fields are filled for all images in File List
  • Backend: content elements have descriptive headers (or
    header_layout = 100
    for hidden)
  • Backend: page properties have proper
    <title>
    and
    description
  • Fluid templates use
    <f:link.page>
    /
    <f:link.typolink>
    (not
    <a>
    with manual hrefs)
  • Content Block / Content Element templates follow accessible patterns below
  • 后端:文件列表中所有图片均填写替代文本字段
  • 后端:内容元素设置描述性标题(或设置
    header_layout = 100
    隐藏标题)
  • 后端:页面属性设置正确的
    <title>
    description
  • Fluid模板使用
    <f:link.page>
    /
    <f:link.typolink>
    (而非手动设置href的
    <a>
    标签)
  • 内容块/内容元素模板遵循下方的无障碍设计模式

2. Fluid Template Patterns

2. Fluid模板设计模式

2.1 Page Layout with Landmarks

2.1 包含地标元素的页面布局

html
<!-- EXT:site_package/Resources/Private/Layouts/Default.html -->
<f:layout name="Default" />

<f:render section="Main" />
html
<!-- EXT:site_package/Resources/Private/Templates/Default.html -->
<f:layout name="Default" />
<f:section name="Main">

<a href="#main-content" class="skip-link">
    <f:translate key="LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:skip_to_content" />
</a>

<header role="banner">
    <nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.main')}">
        <f:cObject typoscriptObjectPath="lib.mainNavigation" />
    </nav>
</header>

<main id="main-content">
    <f:render section="Content" />
</main>

<footer>
    <nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.footer')}">
        <f:cObject typoscriptObjectPath="lib.footerNavigation" />
    </nav>
</footer>

</f:section>
html
<!-- EXT:site_package/Resources/Private/Layouts/Default.html -->
<f:layout name="Default" />

<f:render section="Main" />
html
<!-- EXT:site_package/Resources/Private/Templates/Default.html -->
<f:layout name="Default" />
<f:section name="Main">

<a href="#main-content" class="skip-link">
    <f:translate key="LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:skip_to_content" />
</a>

<header role="banner">
    <nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.main')}">
        <f:cObject typoscriptObjectPath="lib.mainNavigation" />
    </nav>
</header>

<main id="main-content">
    <f:render section="Content" />
</main>

<footer>
    <nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.footer')}">
        <f:cObject typoscriptObjectPath="lib.footerNavigation" />
    </nav>
</footer>

</f:section>

2.2 Skip Link CSS

2.2 跳转链接CSS样式

css
.skip-link {
    position: absolute;
    top: -100%;
    left: 0;
    z-index: 10000;
    padding: 0.75rem 1.5rem;
    background: var(--color-primary, #2563eb);
    color: var(--color-on-primary, #fff);
    font-weight: 600;
    text-decoration: none;
}
.skip-link:focus {
    top: 0;
}
css
.skip-link {
    position: absolute;
    top: -100%;
    left: 0;
    z-index: 10000;
    padding: 0.75rem 1.5rem;
    background: var(--color-primary, #2563eb);
    color: var(--color-on-primary, #fff);
    font-weight: 600;
    text-decoration: none;
}
.skip-link:focus {
    top: 0;
}

2.3 Accessible Image Rendering

2.3 无障碍图片渲染

html
<!-- Partial: Resources/Private/Partials/Media/Image.html -->
<f:if condition="{image}">
    <figure>
        <f:image
            image="{image}"
            alt="{image.alternative}"
            title="{image.title}"
            width="{dimensions.width}"
            height="{dimensions.height}"
            loading="{f:if(condition: '{lazyLoad}', then: 'lazy', else: 'eager')}"
        />
        <f:if condition="{image.description}">
            <figcaption>{image.description}</figcaption>
        </f:if>
    </figure>
</f:if>
For decorative images (no informational value):
html
<f:image image="{image}" alt="" role="presentation" />
html
<!-- Partial: Resources/Private/Partials/Media/Image.html -->
<f:if condition="{image}">
    <figure>
        <f:image
            image="{image}"
            alt="{image.alternative}"
            title="{image.title}"
            width="{dimensions.width}"
            height="{dimensions.height}"
            loading="{f:if(condition: '{lazyLoad}', then: 'lazy', else: 'eager')}"
        />
        <f:if condition="{image.description}">
            <figcaption>{image.description}</figcaption>
        </f:if>
    </figure>
</f:if>
装饰性图片(无信息价值):
html
<f:image image="{image}" alt="" role="presentation" />

2.4 Accessible Content Element Wrapper

2.4 无障碍内容元素包裹器

html
<!-- Partial: Resources/Private/Partials/ContentElement/Header.html -->
<f:if condition="{data.header} && {data.header_layout} != 100">
    <f:switch expression="{data.header_layout}">
        <f:case value="1"><h1>{data.header}</h1></f:case>
        <f:case value="2"><h2>{data.header}</h2></f:case>
        <f:case value="3"><h3>{data.header}</h3></f:case>
        <f:case value="4"><h4>{data.header}</h4></f:case>
        <f:case value="5"><h5>{data.header}</h5></f:case>
        <f:defaultCase><h2>{data.header}</h2></f:defaultCase>
    </f:switch>
</f:if>
html
<!-- Partial: Resources/Private/Partials/ContentElement/Header.html -->
<f:if condition="{data.header} && {data.header_layout} != 100">
    <f:switch expression="{data.header_layout}">
        <f:case value="1"><h1>{data.header}</h1></f:case>
        <f:case value="2"><h2>{data.header}</h2></f:case>
        <f:case value="3"><h3>{data.header}</h3></f:case>
        <f:case value="4"><h4>{data.header}</h4></f:case>
        <f:case value="5"><h5>{data.header}</h5></f:case>
        <f:defaultCase><h2>{data.header}</h2></f:defaultCase>
    </f:switch>
</f:if>

2.5 Accessible Navigation Partial

2.5 无障碍导航局部模板

html
<!-- Partial: Resources/Private/Partials/Navigation/MainMenu.html -->
<nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.main')}">
    <ul role="list">
        <f:for each="{menu}" as="item">
            <li>
                <f:if condition="{item.active}">
                    <f:then>
                        <a href="{item.link}" aria-current="page">{item.title}</a>
                    </f:then>
                    <f:else>
                        <a href="{item.link}">{item.title}</a>
                    </f:else>
                </f:if>

                <f:if condition="{item.children}">
                    <ul>
                        <f:for each="{item.children}" as="child">
                            <li>
                                <a href="{child.link}"
                                   {f:if(condition: '{child.active}', then: 'aria-current="page"')}>
                                    {child.title}
                                </a>
                            </li>
                        </f:for>
                    </ul>
                </f:if>
            </li>
        </f:for>
    </ul>
</nav>
html
<!-- Partial: Resources/Private/Partials/Navigation/MainMenu.html -->
<nav aria-label="{f:translate(key: 'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:nav.main')}">
    <ul role="list">
        <f:for each="{menu}" as="item">
            <li>
                <f:if condition="{item.active}">
                    <f:then>
                        <a href="{item.link}" aria-current="page">{item.title}</a>
                    </f:then>
                    <f:else>
                        <a href="{item.link}">{item.title}</a>
                    </f:else>
                </f:if>

                <f:if condition="{item.children}">
                    <ul>
                        <f:for each="{item.children}" as="child">
                            <li>
                                <a href="{child.link}"
                                   {f:if(condition: '{child.active}', then: 'aria-current="page"')}>
                                    {child.title}
                                </a>
                            </li>
                        </f:for>
                    </ul>
                </f:if>
            </li>
        </f:for>
    </ul>
</nav>

2.6 Accessible Accordion (Content Blocks / Custom CE)

2.6 无障碍手风琴组件(内容块/自定义内容元素)

html
<div class="accordion" data-accordion>
    <f:for each="{items}" as="item" iteration="iter">
        <div class="accordion__item">
            <h3>
                <button
                    type="button"
                    class="accordion__trigger"
                    aria-expanded="false"
                    aria-controls="accordion-panel-{data.uid}-{iter.index}"
                    id="accordion-header-{data.uid}-{iter.index}"
                    data-accordion-trigger
                >
                    {item.header}
                </button>
            </h3>
            <div
                id="accordion-panel-{data.uid}-{iter.index}"
                role="region"
                aria-labelledby="accordion-header-{data.uid}-{iter.index}"
                class="accordion__panel"
                hidden
            >
                <f:format.html>{item.bodytext}</f:format.html>
            </div>
        </div>
    </f:for>
</div>
html
<div class="accordion" data-accordion>
    <f:for each="{items}" as="item" iteration="iter">
        <div class="accordion__item">
            <h3>
                <button
                    type="button"
                    class="accordion__trigger"
                    aria-expanded="false"
                    aria-controls="accordion-panel-{data.uid}-{iter.index}"
                    id="accordion-header-{data.uid}-{iter.index}"
                    data-accordion-trigger
                >
                    {item.header}
                </button>
            </h3>
            <div
                id="accordion-panel-{data.uid}-{iter.index}"
                role="region"
                aria-labelledby="accordion-header-{data.uid}-{iter.index}"
                class="accordion__panel"
                hidden
            >
                <f:format.html>{item.bodytext}</f:format.html>
            </div>
        </div>
    </f:for>
</div>

2.7 Accessible Tab Component

2.7 无障碍标签页组件

html
<div class="tabs" data-tabs>
    <div role="tablist" aria-label="{f:translate(key: 'tabs.label')}">
        <f:for each="{items}" as="item" iteration="iter">
            <button
                type="button"
                role="tab"
                id="tab-{data.uid}-{iter.index}"
                aria-controls="tabpanel-{data.uid}-{iter.index}"
                aria-selected="{f:if(condition: '{iter.isFirst}', then: 'true', else: 'false')}"
                tabindex="{f:if(condition: '{iter.isFirst}', then: '0', else: '-1')}"
            >
                {item.header}
            </button>
        </f:for>
    </div>

    <f:for each="{items}" as="item" iteration="iter">
        <div
            role="tabpanel"
            id="tabpanel-{data.uid}-{iter.index}"
            aria-labelledby="tab-{data.uid}-{iter.index}"
            tabindex="0"
            {f:if(condition: '!{iter.isFirst}', then: 'hidden')}
        >
            <f:format.html>{item.bodytext}</f:format.html>
        </div>
    </f:for>
</div>
html
<div class="tabs" data-tabs>
    <div role="tablist" aria-label="{f:translate(key: 'tabs.label')}">
        <f:for each="{items}" as="item" iteration="iter">
            <button
                type="button"
                role="tab"
                id="tab-{data.uid}-{iter.index}"
                aria-controls="tabpanel-{data.uid}-{iter.index}"
                aria-selected="{f:if(condition: '{iter.isFirst}', then: 'true', else: 'false')}"
                tabindex="{f:if(condition: '{iter.isFirst}', then: '0', else: '-1')}"
            >
                {item.header}
            </button>
        </f:for>
    </div>

    <f:for each="{items}" as="item" iteration="iter">
        <div
            role="tabpanel"
            id="tabpanel-{data.uid}-{iter.index}"
            aria-labelledby="tab-{data.uid}-{iter.index}"
            tabindex="0"
            {f:if(condition: '!{iter.isFirst}', then: 'hidden')}
        >
            <f:format.html>{item.bodytext}</f:format.html>
        </div>
    </f:for>
</div>

3. PHP: Accessibility Middleware & Helpers

3. PHP:无障碍中间件与工具类

3.1 Language Attribute Middleware (PSR-15)

3.1 语言属性中间件(PSR-15)

Ensures
<html lang="...">
is always correct based on site language config.
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Attribute\AsMiddleware;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;

#[AsMiddleware(
    identifier: 'vendor/site-package/accessibility-lang',
    after: 'typo3/cms-frontend/content-length-headers',
)]
final class AccessibilityLangMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        $siteLanguage = $request->getAttribute('language');
        if (!$siteLanguage instanceof SiteLanguage) {
            return $response;
        }

        $contentType = $response->getHeaderLine('Content-Type');
        if (!str_contains($contentType, 'text/html')) {
            return $response;
        }

        $locale = $siteLanguage->getLocale();
        $langCode = $locale->getLanguageCode();
        $body = (string)$response->getBody();

        $body = preg_replace(
            '/<html([^>]*)lang="[^"]*"/',
            '<html$1lang="' . htmlspecialchars($langCode) . '"',
            $body,
            1
        );

        $response->getBody()->rewind();
        $response->getBody()->write($body);

        return $response;
    }
}
v12/v13 fallback: Register via
Configuration/RequestMiddlewares.php
instead of the
#[AsMiddleware]
attribute:
php
<?php
// Configuration/RequestMiddlewares.php (v12/v13)
return [
    'frontend' => [
        'vendor/site-package/accessibility-lang' => [
            'target' => \Vendor\SitePackage\Middleware\AccessibilityLangMiddleware::class,
            'after' => ['typo3/cms-frontend/content-length-headers'],
        ],
    ],
];
确保
<html lang="...">
始终与站点语言配置一致。
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Attribute\AsMiddleware;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;

#[AsMiddleware(
    identifier: 'vendor/site-package/accessibility-lang',
    after: 'typo3/cms-frontend/content-length-headers',
)]
final class AccessibilityLangMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        $siteLanguage = $request->getAttribute('language');
        if (!$siteLanguage instanceof SiteLanguage) {
            return $response;
        }

        $contentType = $response->getHeaderLine('Content-Type');
        if (!str_contains($contentType, 'text/html')) {
            return $response;
        }

        $locale = $siteLanguage->getLocale();
        $langCode = $locale->getLanguageCode();
        $body = (string)$response->getBody();

        $body = preg_replace(
            '/<html([^>]*)lang="[^"]*"/',
            '<html$1lang="' . htmlspecialchars($langCode) . '"',
            $body,
            1
        );

        $response->getBody()->rewind();
        $response->getBody()->write($body);

        return $response;
    }
}
v12/v13回退方案: 不使用
#[AsMiddleware]
属性,而是通过
Configuration/RequestMiddlewares.php
注册:
php
<?php
// Configuration/RequestMiddlewares.php (v12/v13)
return [
    'frontend' => [
        'vendor/site-package/accessibility-lang' => [
            'target' => \Vendor\SitePackage\Middleware\AccessibilityLangMiddleware::class,
            'after' => ['typo3/cms-frontend/content-length-headers'],
        ],
    ],
];

3.2 Image Alt Text Validation (PSR-14 Event)

3.2 图片替代文本验证(PSR-14事件)

Warn editors when images lack alt text:
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;

#[AsEventListener(identifier: 'vendor/site-package/alt-text-check')]
final class ImageAltTextValidationListener
{
    public function __construct(
        private readonly FlashMessageService $flashMessageService,
    ) {}

    public function __invoke(\TYPO3\CMS\Core\DataHandling\Event\AfterRecordPublishedEvent|object $event): void
    {
        // For DataHandler processDatamap_afterDatabaseOperations hook style usage:
        // This can be adapted as a DataHandler hook or PSR-14 event depending on version.
    }

    public function processDatamap_afterDatabaseOperations(
        string $status,
        string $table,
        string|int $id,
        array $fieldArray,
        DataHandler $dataHandler,
    ): void {
        if ($table !== 'sys_file_metadata') {
            return;
        }

        if (empty($fieldArray['alternative'] ?? '')) {
            $message = GeneralUtility::makeInstance(
                FlashMessage::class,
                'Image is missing alt text. Please add descriptive alt text for accessibility (WCAG 1.1.1).',
                'Accessibility Warning',
                ContextualFeedbackSeverity::WARNING,
                true,
            );

            $this->flashMessageService
                ->getMessageQueueByIdentifier()
                ->addMessage($message);
        }
    }
}
Register as DataHandler hook in
ext_localconf.php
:
php
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][]
    = \Vendor\SitePackage\EventListener\ImageAltTextValidationListener::class;
当图片缺少替代文本时,向编辑器发出警告:
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;

#[AsEventListener(identifier: 'vendor/site-package/alt-text-check')]
final class ImageAltTextValidationListener
{
    public function __construct(
        private readonly FlashMessageService $flashMessageService,
    ) {}

    public function __invoke(\TYPO3\CMS\Core\DataHandling\Event\AfterRecordPublishedEvent|object $event): void
    {
        // 适配DataHandler的processDatamap_afterDatabaseOperations钩子用法:
        // 可根据版本调整为DataHandler钩子或PSR-14事件
    }

    public function processDatamap_afterDatabaseOperations(
        string $status,
        string $table,
        string|int $id,
        array $fieldArray,
        DataHandler $dataHandler,
    ): void {
        if ($table !== 'sys_file_metadata') {
            return;
        }

        if (empty($fieldArray['alternative'] ?? '')) {
            $message = GeneralUtility::makeInstance(
                FlashMessage::class,
                '图片缺少替代文本,请添加描述性替代文本以符合无障碍标准(WCAG 1.1.1)。',
                '无障碍警告',
                ContextualFeedbackSeverity::WARNING,
                true,
            );

            $this->flashMessageService
                ->getMessageQueueByIdentifier()
                ->addMessage($message);
        }
    }
}
ext_localconf.php
中注册为DataHandler钩子:
php
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][]
    = \Vendor\SitePackage\EventListener\ImageAltTextValidationListener::class;

3.3 Accessible Fluid ViewHelper: SrOnly

3.3 无障碍Fluid ViewHelper:SrOnly

Renders screen-reader-only text:
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\ViewHelpers;

use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper;

final class SrOnlyViewHelper extends AbstractTagBasedViewHelper
{
    protected $tagName = 'span';

    public function initializeArguments(): void
    {
        parent::initializeArguments();
        $this->registerUniversalTagAttributes();
    }

    public function render(): string
    {
        $this->tag->addAttribute('class', 'sr-only');
        $this->tag->setContent($this->renderChildren());
        return $this->tag->render();
    }
}
css
/* CSS for sr-only */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border-width: 0;
}
Usage in Fluid:
html
{namespace sp = Vendor\SitePackage\ViewHelpers}
<button aria-label="{f:translate(key: 'button.close')}">
    <svg aria-hidden="true"><!-- icon --></svg>
    <sp:srOnly><f:translate key="button.close" /></sp:srOnly>
</button>
仅为屏幕阅读器渲染文本:
php
<?php

declare(strict_types=1);

namespace Vendor\SitePackage\ViewHelpers;

use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper;

final class SrOnlyViewHelper extends AbstractTagBasedViewHelper
{
    protected $tagName = 'span';

    public function initializeArguments(): void
    {
        parent::initializeArguments();
        $this->registerUniversalTagAttributes();
    }

    public function render(): string
    {
        $this->tag->addAttribute('class', 'sr-only');
        $this->tag->setContent($this->renderChildren());
        return $this->tag->render();
    }
}
css
/* SrOnly样式的CSS */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border-width: 0;
}
Fluid中的用法:
html
{namespace sp = Vendor\SitePackage\ViewHelpers}
<button aria-label="{f:translate(key: 'button.close')}">
    <svg aria-hidden="true"><!-- 图标 --></svg>
    <sp:srOnly><f:translate key="button.close" /></sp:srOnly>
</button>

4. JavaScript: Accessible Widgets

4. JavaScript:无障碍组件

4.1 Accordion

4.1 手风琴组件

javascript
// EXT:site_package/Resources/Public/JavaScript/accordion.js
class AccessibleAccordion {
    constructor(container) {
        this.container = container;
        this.triggers = container.querySelectorAll('[data-accordion-trigger]');
        this.init();
    }

    init() {
        this.triggers.forEach((trigger) => {
            trigger.addEventListener('click', () => this.toggle(trigger));
            trigger.addEventListener('keydown', (e) => this.handleKeydown(e));
        });
    }

    toggle(trigger) {
        const expanded = trigger.getAttribute('aria-expanded') === 'true';
        const panelId = trigger.getAttribute('aria-controls');
        const panel = document.getElementById(panelId);

        trigger.setAttribute('aria-expanded', String(!expanded));
        panel.hidden = expanded;
    }

    handleKeydown(e) {
        const triggers = [...this.triggers];
        const index = triggers.indexOf(e.currentTarget);

        switch (e.key) {
            case 'ArrowDown':
                e.preventDefault();
                triggers[(index + 1) % triggers.length].focus();
                break;
            case 'ArrowUp':
                e.preventDefault();
                triggers[(index - 1 + triggers.length) % triggers.length].focus();
                break;
            case 'Home':
                e.preventDefault();
                triggers[0].focus();
                break;
            case 'End':
                e.preventDefault();
                triggers[triggers.length - 1].focus();
                break;
        }
    }
}

document.querySelectorAll('[data-accordion]').forEach((el) => new AccessibleAccordion(el));
javascript
// EXT:site_package/Resources/Public/JavaScript/accordion.js
class AccessibleAccordion {
    constructor(container) {
        this.container = container;
        this.triggers = container.querySelectorAll('[data-accordion-trigger]');
        this.init();
    }

    init() {
        this.triggers.forEach((trigger) => {
            trigger.addEventListener('click', () => this.toggle(trigger));
            trigger.addEventListener('keydown', (e) => this.handleKeydown(e));
        });
    }

    toggle(trigger) {
        const expanded = trigger.getAttribute('aria-expanded') === 'true';
        const panelId = trigger.getAttribute('aria-controls');
        const panel = document.getElementById(panelId);

        trigger.setAttribute('aria-expanded', String(!expanded));
        panel.hidden = expanded;
    }

    handleKeydown(e) {
        const triggers = [...this.triggers];
        const index = triggers.indexOf(e.currentTarget);

        switch (e.key) {
            case 'ArrowDown':
                e.preventDefault();
                triggers[(index + 1) % triggers.length].focus();
                break;
            case 'ArrowUp':
                e.preventDefault();
                triggers[(index - 1 + triggers.length) % triggers.length].focus();
                break;
            case 'Home':
                e.preventDefault();
                triggers[0].focus();
                break;
            case 'End':
                e.preventDefault();
                triggers[triggers.length - 1].focus();
                break;
        }
    }
}

document.querySelectorAll('[data-accordion]').forEach((el) => new AccessibleAccordion(el));

4.2 Tabs

4.2 标签页组件

javascript
// EXT:site_package/Resources/Public/JavaScript/tabs.js
class AccessibleTabs {
    constructor(container) {
        this.tablist = container.querySelector('[role="tablist"]');
        this.tabs = [...this.tablist.querySelectorAll('[role="tab"]')];
        this.panels = this.tabs.map(
            (tab) => document.getElementById(tab.getAttribute('aria-controls'))
        );
        this.init();
    }

    init() {
        this.tabs.forEach((tab) => {
            tab.addEventListener('click', () => this.selectTab(tab));
            tab.addEventListener('keydown', (e) => this.handleKeydown(e));
        });
    }

    selectTab(selectedTab) {
        this.tabs.forEach((tab, i) => {
            const selected = tab === selectedTab;
            tab.setAttribute('aria-selected', String(selected));
            tab.tabIndex = selected ? 0 : -1;
            this.panels[i].hidden = !selected;
        });
        selectedTab.focus();
    }

    handleKeydown(e) {
        const index = this.tabs.indexOf(e.currentTarget);
        let next;

        switch (e.key) {
            case 'ArrowRight':
                next = (index + 1) % this.tabs.length;
                break;
            case 'ArrowLeft':
                next = (index - 1 + this.tabs.length) % this.tabs.length;
                break;
            case 'Home':
                next = 0;
                break;
            case 'End':
                next = this.tabs.length - 1;
                break;
            default:
                return;
        }
        e.preventDefault();
        this.selectTab(this.tabs[next]);
    }
}

document.querySelectorAll('[data-tabs]').forEach((el) => new AccessibleTabs(el));
javascript
// EXT:site_package/Resources/Public/JavaScript/tabs.js
class AccessibleTabs {
    constructor(container) {
        this.tablist = container.querySelector('[role="tablist"]');
        this.tabs = [...this.tablist.querySelectorAll('[role="tab"]')];
        this.panels = this.tabs.map(
            (tab) => document.getElementById(tab.getAttribute('aria-controls'))
        );
        this.init();
    }

    init() {
        this.tabs.forEach((tab) => {
            tab.addEventListener('click', () => this.selectTab(tab));
            tab.addEventListener('keydown', (e) => this.handleKeydown(e));
        });
    }

    selectTab(selectedTab) {
        this.tabs.forEach((tab, i) => {
            const selected = tab === selectedTab;
            tab.setAttribute('aria-selected', String(selected));
            tab.tabIndex = selected ? 0 : -1;
            this.panels[i].hidden = !selected;
        });
        selectedTab.focus();
    }

    handleKeydown(e) {
        const index = this.tabs.indexOf(e.currentTarget);
        let next;

        switch (e.key) {
            case 'ArrowRight':
                next = (index + 1) % this.tabs.length;
                break;
            case 'ArrowLeft':
                next = (index - 1 + this.tabs.length) % this.tabs.length;
                break;
            case 'Home':
                next = 0;
                break;
            case 'End':
                next = this.tabs.length - 1;
                break;
            default:
                return;
        }
        e.preventDefault();
        this.selectTab(this.tabs[next]);
    }
}

document.querySelectorAll('[data-tabs]').forEach((el) => new AccessibleTabs(el));

4.3 Modal / Dialog

4.3 模态框/对话框

javascript
// EXT:site_package/Resources/Public/JavaScript/dialog.js
class AccessibleDialog {
    constructor(dialog) {
        this.dialog = dialog;
        this.previousFocus = null;
        this.init();
    }

    init() {
        this.dialog.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') this.close();
            if (e.key === 'Tab') this.trapFocus(e);
        });

        this.dialog.addEventListener('click', (e) => {
            if (e.target === this.dialog) this.close();
        });
    }

    open() {
        this.previousFocus = document.activeElement;
        this.dialog.showModal();

        const firstFocusable = this.dialog.querySelector(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        firstFocusable?.focus();
    }

    close() {
        this.dialog.close();
        this.previousFocus?.focus();
    }

    trapFocus(e) {
        const focusable = this.dialog.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];

        if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
        }
    }
}
Use the native
<dialog>
element in Fluid:
html
<dialog id="my-dialog" aria-labelledby="dialog-title" aria-modal="true">
    <h2 id="dialog-title">{dialogTitle}</h2>
    <div>{dialogContent}</div>
    <button type="button" data-close-dialog>
        <f:translate key="button.close" />
    </button>
</dialog>
javascript
// EXT:site_package/Resources/Public/JavaScript/dialog.js
class AccessibleDialog {
    constructor(dialog) {
        this.dialog = dialog;
        this.previousFocus = null;
        this.init();
    }

    init() {
        this.dialog.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') this.close();
            if (e.key === 'Tab') this.trapFocus(e);
        });

        this.dialog.addEventListener('click', (e) => {
            if (e.target === this.dialog) this.close();
        });
    }

    open() {
        this.previousFocus = document.activeElement;
        this.dialog.showModal();

        const firstFocusable = this.dialog.querySelector(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        firstFocusable?.focus();
    }

    close() {
        this.dialog.close();
        this.previousFocus?.focus();
    }

    trapFocus(e) {
        const focusable = this.dialog.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];

        if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
        }
    }
}
在Fluid中使用原生
<dialog>
元素:
html
<dialog id="my-dialog" aria-labelledby="dialog-title" aria-modal="true">
    <h2 id="dialog-title">{dialogTitle}</h2>
    <div>{dialogContent}</div>
    <button type="button" data-close-dialog>
        <f:translate key="button.close" />
    </button>
</dialog>

4.4 Reduced Motion Check

4.4 减少动效检测

javascript
// EXT:site_package/Resources/Public/JavaScript/motion.js
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function getTransitionDuration() {
    return prefersReducedMotion.matches ? 0 : 300;
}

prefersReducedMotion.addEventListener('change', () => {
    document.documentElement.classList.toggle('reduce-motion', prefersReducedMotion.matches);
});

if (prefersReducedMotion.matches) {
    document.documentElement.classList.add('reduce-motion');
}
css
@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;
    }
}
javascript
// EXT:site_package/Resources/Public/JavaScript/motion.js
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function getTransitionDuration() {
    return prefersReducedMotion.matches ? 0 : 300;
}

prefersReducedMotion.addEventListener('change', () => {
    document.documentElement.classList.toggle('reduce-motion', prefersReducedMotion.matches);
});

if (prefersReducedMotion.matches) {
    document.documentElement.classList.add('reduce-motion');
}
css
@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;
    }
}

4.5 Live Region Announcer

4.5 实时区域播报器

javascript
// EXT:site_package/Resources/Public/JavaScript/announcer.js
class LiveAnnouncer {
    constructor() {
        this.region = document.createElement('div');
        this.region.setAttribute('aria-live', 'polite');
        this.region.setAttribute('aria-atomic', 'true');
        this.region.classList.add('sr-only');
        document.body.appendChild(this.region);
    }

    announce(message, priority = 'polite') {
        this.region.setAttribute('aria-live', priority);
        this.region.textContent = '';
        requestAnimationFrame(() => {
            this.region.textContent = message;
        });
    }
}

window.liveAnnouncer = new LiveAnnouncer();
Usage:
window.liveAnnouncer.announce('3 results found');
javascript
// EXT:site_package/Resources/Public/JavaScript/announcer.js
class LiveAnnouncer {
    constructor() {
        this.region = document.createElement('div');
        this.region.setAttribute('aria-live', 'polite');
        this.region.setAttribute('aria-atomic', 'true');
        this.region.classList.add('sr-only');
        document.body.appendChild(this.region);
    }

    announce(message, priority = 'polite') {
        this.region.setAttribute('aria-live', priority);
        this.region.textContent = '';
        requestAnimationFrame(() => {
            this.region.textContent = message;
        });
    }
}

window.liveAnnouncer = new LiveAnnouncer();
用法:
window.liveAnnouncer.announce('找到3条结果');

5. CSS: Focus & Contrast Essentials

5. CSS:焦点与对比度核心样式

css
/* EXT:site_package/Resources/Public/Css/accessibility.css */

/* Visible focus indicator */
:focus-visible {
    outline: 3px solid var(--focus-color, #2563eb);
    outline-offset: 2px;
}

:focus:not(:focus-visible) {
    outline: none;
}

/* Touch targets */
button, a, input, select, textarea, [role="button"] {
    min-height: 44px;
    min-width: 44px;
}

/* Ensure readable line height (WCAG 1.4.12) */
body {
    line-height: 1.6;
}

p + p {
    margin-top: 1em;
}

/* Max line length for readability */
.ce-bodytext, .frame-default .content {
    max-width: 75ch;
}

/* Scroll margin for anchored headings */
[id] {
    scroll-margin-top: 5rem;
}

/* Hover + focus parity */
a:hover, a:focus-visible,
button:hover, button:focus-visible {
    text-decoration: underline;
}
css
/* EXT:site_package/Resources/Public/Css/accessibility.css */

/* 可见的焦点指示器 */
:focus-visible {
    outline: 3px solid var(--focus-color, #2563eb);
    outline-offset: 2px;
}

:focus:not(:focus-visible) {
    outline: none;
}

/* 触摸目标尺寸 */
button, a, input, select, textarea, [role="button"] {
    min-height: 44px;
    min-width: 44px;
}

/* 确保行高可读性(WCAG 1.4.12) */
body {
    line-height: 1.6;
}

p + p {
    margin-top: 1em;
}

/* 最大行宽优化可读性 */
.ce-bodytext, .frame-default .content {
    max-width: 75ch;
}

/* 锚点标题的滚动边距 */
[id] {
    scroll-margin-top: 5rem;
}

/* 悬停与焦点样式一致性 */
a:hover, a:focus-visible,
button:hover, button:focus-visible {
    text-decoration: underline;
}

6. TYPO3-Specific Configuration

6. TYPO3专属配置

6.1 TCA: Require Alt Text on Images

6.1 TCA:强制图片添加替代文本

Make the alt text field required in file metadata:
php
<?php
// Configuration/TCA/Overrides/sys_file_metadata.php

$GLOBALS['TCA']['sys_file_metadata']['columns']['alternative']['config']['required'] = true;

// v12/v13 fallback: use eval instead
// $GLOBALS['TCA']['sys_file_metadata']['columns']['alternative']['config']['eval'] = 'required';
在文件元数据中设置替代文本为必填项:
php
<?php
// Configuration/TCA/Overrides/sys_file_metadata.php

$GLOBALS['TCA']['sys_file_metadata']['columns']['alternative']['config']['required'] = true;

// v12/v13回退方案:使用eval替代
// $GLOBALS['TCA']['sys_file_metadata']['columns']['alternative']['config']['eval'] = 'required';

6.2 TCA: Header Layout Options

6.2 TCA:标题层级选项

Provide proper heading levels and a "hidden" option for content elements:
php
<?php
// Configuration/TCA/Overrides/tt_content.php

$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
    ['label' => 'H1', 'value' => '1'],
    ['label' => 'H2', 'value' => '2'],
    ['label' => 'H3', 'value' => '3'],
    ['label' => 'H4', 'value' => '4'],
    ['label' => 'H5', 'value' => '5'],
    ['label' => 'Hidden', 'value' => '100'],
];
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['default'] = '2';
v12 fallback: Use numeric array keys
[0]
= label,
[1]
= value:
php
// v12 style
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
    ['H1', '1'],
    ['H2', '2'],
    ['H3', '3'],
    ['H4', '4'],
    ['H5', '5'],
    ['Hidden', '100'],
];
为内容元素提供合理的标题层级和“隐藏”选项:
php
<?php
// Configuration/TCA/Overrides/tt_content.php

$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
    ['label' => 'H1', 'value' => '1'],
    ['label' => 'H2', 'value' => '2'],
    ['label' => 'H3', 'value' => '3'],
    ['label' => 'H4', 'value' => '4'],
    ['label' => 'H5', 'value' => '5'],
    ['label' => '隐藏', 'value' => '100'],
];
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['default'] = '2';
v12回退方案: 使用数字数组键,
[0]
为标签,
[1]
为值:
php
// v12格式
$GLOBALS['TCA']['tt_content']['columns']['header_layout']['config']['items'] = [
    ['H1', '1'],
    ['H2', '2'],
    ['H3', '3'],
    ['H4', '4'],
    ['H5', '5'],
    ['隐藏', '100'],
];

6.3 Form Framework: Accessible Forms (EXT:form)

6.3 表单框架:无障碍表单(EXT:form)

yaml
undefined
yaml
undefined

EXT:site_package/Configuration/Form/Overrides/AccessibleForm.yaml

EXT:site_package/Configuration/Form/Overrides/AccessibleForm.yaml

TYPO3: CMS: Form: prototypes: standard: formElementsDefinition: Text: properties: fluidAdditionalAttributes: autocomplete: 'on' Email: properties: fluidAdditionalAttributes: autocomplete: 'email' inputmode: 'email' Telephone: properties: fluidAdditionalAttributes: autocomplete: 'tel' inputmode: 'tel'
undefined
TYPO3: CMS: Form: prototypes: standard: formElementsDefinition: Text: properties: fluidAdditionalAttributes: autocomplete: 'on' Email: properties: fluidAdditionalAttributes: autocomplete: 'email' inputmode: 'email' Telephone: properties: fluidAdditionalAttributes: autocomplete: 'tel' inputmode: 'tel'
undefined

6.4 Content Security Policy: Allow Inline Focus Styles

6.4 内容安全策略:允许内联焦点样式

If CSP is enabled, ensure focus styles via external CSS (not inline):
php
<?php
// No inline styles needed — use the external accessibility.css
// If you must add inline styles, extend CSP via PSR-14:

declare(strict_types=1);

namespace Vendor\SitePackage\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue;

#[AsEventListener(identifier: 'vendor/site-package/csp-accessibility')]
final class CspAccessibilityListener
{
    public function __invoke(PolicyMutatedEvent $event): void
    {
        if ($event->getScope()->type->isFrontend()) {
            // Prefer external CSS over extending CSP for inline styles
        }
    }
}
若启用CSP,请确保通过外部CSS实现焦点样式(而非内联样式):
php
<?php
// 无需内联样式 — 使用外部accessibility.css即可
// 若必须添加内联样式,可通过PSR-14扩展CSP:

declare(strict_types=1);

namespace Vendor\SitePackage\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue;

#[AsEventListener(identifier: 'vendor/site-package/csp-accessibility')]
final class CspAccessibilityListener
{
    public function __invoke(PolicyMutatedEvent $event): void
    {
        if ($event->getScope()->type->isFrontend()) {
            // 优先使用外部CSS,而非扩展CSP支持内联样式
        }
    }
}

7. Testing & Tools

7. 测试与工具

Automated Testing

自动化测试

ToolUse
axe-core
/
axe DevTools
Browser extension for automated WCAG checks
pa11y
CLI accessibility testing for CI/CD
Lighthouse
Chrome DevTools audit (Accessibility score)
WAVE
Browser extension for visual feedback
工具用途
axe-core
/
axe DevTools
浏览器扩展,用于自动化WCAG合规性检查
pa11y
CLI工具,用于CI/CD流程中的无障碍测试
Lighthouse
Chrome开发者工具中的审计功能(包含无障碍评分)
WAVE
浏览器扩展,提供可视化的无障碍问题反馈

Manual Testing Checklist

手动测试检查清单

  1. Keyboard only: Navigate entire page using Tab, Shift+Tab, Enter, Escape, Arrow keys
  2. Screen reader: Test with VoiceOver (macOS), NVDA/JAWS (Windows)
  3. Zoom: Verify layout at 200% and 400% zoom
  4. Color: Use browser dev tools to simulate color blindness
  5. Reduced motion: Enable
    prefers-reduced-motion: reduce
    in OS settings
  1. 仅使用键盘:仅通过Tab、Shift+Tab、Enter、Escape、方向键导航整个页面
  2. 屏幕阅读器:使用VoiceOver(macOS)、NVDA/JAWS(Windows)进行测试
  3. 缩放测试:验证页面在200%和400%缩放比例下的布局是否正常
  4. 色彩模拟:使用浏览器开发者工具模拟色盲模式
  5. 减少动效:在系统设置中启用
    prefers-reduced-motion: reduce

Pa11y CI Integration

Pa11y CI集成

bash
undefined
bash
undefined

Run against local DDEV instance

针对本地DDEV实例运行测试

npx pa11y https://mysite.ddev.site --standard WCAG2AA --reporter cli

```json
// .pa11yci.json
{
    "defaults": {
        "standard": "WCAG2AA",
        "timeout": 30000,
        "wait": 2000
    },
    "urls": [
        "https://mysite.ddev.site/",
        "https://mysite.ddev.site/contact",
        "https://mysite.ddev.site/news"
    ]
}
npx pa11y https://mysite.ddev.site --standard WCAG2AA --reporter cli

```json
// .pa11yci.json
{
    "defaults": {
        "standard": "WCAG2AA",
        "timeout": 30000,
        "wait": 2000
    },
    "urls": [
        "https://mysite.ddev.site/",
        "https://mysite.ddev.site/contact",
        "https://mysite.ddev.site/news"
    ]
}

8. Accessibility Extensions

8. 无障碍扩展推荐

ExtensionPurposev12v13v14
typo3/cms-core
Built-in ARIA, focus management
brotkrueml/schema
Structured data (WebPage, Article)
wapplersystems/a11y
Accessibility toolbar for frontend
b13/container
Accessible grid/container layouts
扩展用途v12v13v14
typo3/cms-core
内置ARIA支持、焦点管理
brotkrueml/schema
结构化数据(WebPage、Article)
wapplersystems/a11y
前端无障碍工具栏
b13/container
无障碍网格/容器布局

9. WCAG 2.2 Quick Reference

9. WCAG 2.2速查指南

Success CriterionLevelTYPO3 Solution
1.1.1 Non-text ContentA
alt
attribute on
<f:image>
, TCA required field
1.3.1 Info and RelationshipsASemantic HTML in Fluid,
<fieldset>
/
<legend>
1.3.5 Identify Input PurposeAA
autocomplete
attributes on form fields
1.4.1 Use of ColorAIcons + text alongside color indicators
1.4.3 Contrast (Minimum)AACSS variables with 4.5:1 ratio
1.4.4 Resize TextAA
rem
units, no
user-scalable=no
1.4.10 ReflowAAFluid/responsive layouts
1.4.11 Non-text ContrastAA3:1 for UI components
1.4.12 Text SpacingAA
line-height: 1.6
, flexible containers
2.1.1 KeyboardANative elements, keyboard handlers
2.4.1 Bypass BlocksASkip link
2.4.3 Focus OrderALogical DOM order
2.4.7 Focus VisibleAA
:focus-visible
styles
2.4.11 Focus Not ObscuredAA
scroll-margin-top
, no sticky overlaps
2.5.8 Target SizeAA44x44px minimum
3.1.1 Language of PageA
<html lang>
via middleware
3.1.2 Language of PartsAA
lang
attribute on multilingual content
3.3.1 Error IdentificationA
aria-invalid
,
aria-describedby
3.3.2 Labels or InstructionsA
<label>
on every input
4.1.2 Name, Role, ValueAARIA attributes on custom widgets
4.1.3 Status MessagesAA
aria-live
regions
成功标准级别TYPO3实现方案
1.1.1 非文本内容A
<f:image>
添加
alt
属性,TCA设置为必填字段
1.3.1 信息与关系AFluid中使用语义化HTML,
<fieldset>
/
<legend>
结构
1.3.5 识别输入用途AA表单字段添加
autocomplete
属性
1.4.1 色彩使用A色彩标识搭配图标+文字
1.4.3 最低对比度AA使用对比度≥4.5:1的CSS变量
1.4.4 文本缩放AA使用
rem
单位,禁止设置
user-scalable=no
1.4.10 重排AA流式/响应式布局
1.4.11 非文本对比度AAUI组件对比度≥3:1
1.4.12 文本间距AA设置
line-height: 1.6
,使用弹性容器
2.1.1 键盘可达性A使用原生元素,实现键盘事件处理
2.4.1 绕过区块A添加跳转链接
2.4.3 焦点顺序A符合逻辑的DOM顺序
2.4.7 可见焦点AA使用
:focus-visible
样式
2.4.11 焦点不被遮挡AA设置
scroll-margin-top
,避免固定浮层遮挡
2.5.8 目标尺寸AA触摸目标≥44x44px
3.1.1 页面语言A通过中间件设置
<html lang>
3.1.2 局部语言AA多语言内容添加
lang
属性
3.3.1 错误识别A使用
aria-invalid
aria-describedby
3.3.2 标签或说明A每个输入框关联
<label>
4.1.2 名称、角色、值A自定义组件添加ARIA属性
4.1.3 状态消息AA使用
aria-live
区域

10. Anti-Patterns (Flag These in Reviews)

10. 反模式(代码评审中需标记)

Anti-PatternFix
<div onclick="...">
Use
<button>
outline: none
without replacement
Use
:focus-visible
with custom outline
placeholder
as sole label
Add
<label>
element
tabindex > 0
Use
tabindex="0"
or natural DOM order
user-scalable=no
in viewport
Remove it
font-size: 12px
(absolute)
Use
rem
units
Images without
alt
Add
alt="..."
or
alt=""
for decorative
Color-only error indicationAdd icon + text
<a>
without
href
for action
Use
<button>
Missing
lang
on
<html>
Set via middleware or Fluid layout
Auto-playing video with soundAdd
muted
or remove
autoplay
Skipped heading levelsFix hierarchy

反模式修复方案
<div onclick="...">
替换为
<button>
outline: none
且无替代方案
使用
:focus-visible
自定义焦点样式
仅使用占位符作为标签添加
<label>
元素
tabindex > 0
使用
tabindex="0"
或调整DOM自然顺序
视口中设置
user-scalable=no
删除该设置
使用绝对单位
font-size: 12px
改用
rem
单位
图片无
alt
属性
添加
alt="..."
alt=""
(装饰性图片)
仅依赖色彩传递错误信息添加图标+文字提示
href
<a>
标签用于交互
替换为
<button>
<html>
标签缺少
lang
属性
通过中间件或Fluid布局设置
自动播放带声音的视频添加
muted
属性或移除
autoplay
标题层级跳过修复为连续层级

Related Skills

相关技能

  • web-design-guidelines - General web accessibility audit (non-TYPO3)
  • web-platform-design - WCAG 2.2, responsive design, forms
  • typo3-seo - SEO and structured data (overlaps with a11y for alt text, headings)
  • typo3-content-blocks - Accessible Content Element templates

  • web-design-guidelines - 通用网页无障碍审计(非TYPO3专属)
  • web-platform-design - WCAG 2.2、响应式设计、表单适配
  • typo3-seo - SEO与结构化数据(与无障碍在替代文本、标题等方面有重叠)
  • typo3-content-blocks - 无障碍内容元素模板

Credits & Attribution

致谢与引用

Accessibility patterns adapted from:
Thanks to Netresearch DTT GmbH for their contributions to the TYPO3 community.
无障碍设计模式改编自:
感谢Netresearch DTT GmbH为TYPO3社区做出的贡献。