标签 - 从头开始
#javascript #css #a11y #animation

让我们从头开始构建此选项卡组件:

带有视图动画API
的选项卡

html

让我们设置拥有最好的A11Y所需的所有属性。

选项卡

父元素具有aria-orientation,可以让用户知道如何在选项卡之间导航。此元素没有特定的role

<div class="tabs" aria-orientation="horizontal" >
  ...
</div>

小表

tablist元素将管理选项卡按钮列表。它还将处理选项卡之间的键盘导航(请参见下文):

<div role="tablist" aria-label="Sample Tabs">
  ...
</div>

注意:无需使用ul元素具有tablist的角色。

标签

每个tab元素将是一个按钮来管理焦点和默认键盘单击。让我们查看所需的属性:

  • role="tab"
  • type="button":如果选项卡在<form>元素中,则防止按钮为submit
  • id="tab-1":需要将选项卡与面板的标签相关联
  • aria-selected="true":如果当前选择了选项卡
  • aria-controls="panel-1":由选项卡控制的面板的ID
  • tabindex="0":用于在选定的选项卡和它的面板之间释放导航
<button
  role="tab"
  type="button"
  aria-selected="true"
  aria-controls="panel-1"
  id="tab-1"
  tabindex="0">
  First Tab
</button>
<button
  role="tab"
  type="button"
  aria-selected="false"
  aria-controls="panel-2"
  id="tab-2"
  tabindex="-1">
  Second Tab
</button>

默认情况下选择第一个选项卡,因此aria-selected设置为“ true”,tabindex设置为0。第二个选项卡将tabindex设置为-1,因此它不能以Tab进行焦点,我们将与Tab一起实现焦点,我们将使用焦点。箭头键稍后。

TABPANEL

tabpanel.tabs元素的直接子女。让我们回顾一下他们的属性:

  • role="tabpanel"
  • id="panel-1":Tab的aria-controls使用
  • aria-labelledby="tab-1":该面板由其标签标记
  • tabindex="0":所有面板都应集中(默认情况下不可集中)
  • hidden:隐藏所有未选择的面板
<div role="tabpanel" id="panel-1" tabindex="0" aria-labelledby="tab-1">
  <p>Content for the first panel</p>
</div>
<div role="tabpanel" id="panel-2" tabindex="0" aria-labelledby="tab-2" hidden>
  <p>Content for the second panel</p>
</div>

HTML结果

这是最终结果:

<div class="tabs" aria-orientation="horizontal" >
  <div role="tablist" aria-label="Sample Tabs">
    <button
      role="tab"
      type="button"
      aria-selected="true"
      aria-controls="panel-1"
      id="tab-1"
      tabindex="0">
      First Tab
    </button>
    <button
      role="tab"
      type="button"
      aria-selected="false"
      aria-controls="panel-2"
      id="tab-2"
      tabindex="-1">
      Second Tab
    </button>
  </div>
  <div role="tabpanel" id="panel-1" tabindex="0" aria-labelledby="tab-1">
    <p>Content for the first panel</p>
  </div>
  <div role="tabpanel" id="panel-2" tabindex="0" aria-labelledby="tab-2" hidden>
    <p>Content for the second panel</p>
  </div>
</div>

JavaScript

让我们与我们的标签互动

点击

当我们单击选项卡时,我们要选择它:

  • 在上一个和下一个选项卡上更改aria-selectedtabindex
  • 更改上一个和下一个面板上的hidden

让我们在选项卡上添加一个事件侦听器:

<button role="tab" ... onclick="select(event)">...</button>

在脚本中,让我们处理此事件:

function select(event) {
  // Get the element
  const nextTab = event.target;
  const panelId = nextTab.getAttribute('aria-controls');
  const previousPanel = document.querySelector('[role="tabpanel"]:not([hidden])');
  // If next panel id if the same as the previous one, stop
  if (previousPanel.id === panelId) return;
  const previousTab = document.querySelector(`[role="tab"][aria-controls="${previousPanel.id}"]`);
  const panel = document.getElementById(panelId);

  // update DOM
  previousTab.setAttribute('aria-selected', 'false');
  previousTab.setAttribute('tabindex', '-1');
  previousPanel.setAttribute('hidden', '');
  nextTab.setAttribute('aria-selected', 'true');
  nextTab.setAttribute('tabindex', '0');
  nextPanel.removeAttribute('hidden');
  // If there is a scrollbar, scroll into view
  nextTab.scrollIntoView({
    behavior: 'smooth',
    block: 'nearest',
  });
}

键盘导航

我们已经使用tabindex="-1"删除了默认的Tab导航,现在该使用箭头键进行管理。

aria-orientation="horizontal"中我们想要:

  • ArrowRight:焦点下一个选项卡
  • ArrowLeft:焦点上一个选项卡
  • Home:焦点的第一个选项卡
  • End:焦点的最后一个选项卡

aria-orientation="vertical"中我们想要:

  • ArrowDown:焦点下一个选项卡
  • ArrowUp:焦点上一个选项卡
  • Home:焦点的第一个选项卡
  • End:焦点的最后一个选项卡

让我们在tablist元素上添加事件侦听器:

<div role="tablist" ... onkeydown="navigate(event)">
  ...
</div>

且在脚本中处理事件

// Query elements (do it on each function if tabs are dynamic)
const root = document.querySelector('.tabs');
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));


function focusFirstTab() {
  tabs.at(0)?.focus();
}

function focusLastTab() {
  tabs.at(-1)?.focus();
}

function focusNextTab() {
    const focusedEl = document.activeElement;
    if (!focusedEl) return focusFirstTab();
    const index = Array.from(tabs).findIndex((el) => el === focusedEl)
    const nextIndex = (index + 1) % tabs.length;
    tabs[nextIndex].focus();
}

function focusPreviousTab() {
    const focusedEl = document.activeElement;
    if (!focusedEl) return focusLastTab();
    const index = Array.from(tabs).findIndex((el) => el === focusedEl)
    const nextIndex = (index - 1 + tabs.length) % tabs.length;
    tabs[nextIndex].focus();
}



/** Navigate between tabs */
function navigate(event) {
  const isHorizontal = root.getAttribute('aria-orientation') === 'horizontal';
  if (isHorizontal) {
    // Prevent horizontal scroll
    if (['ArrowRight', 'ArrowLeft', 'End', 'Home'].includes(event.key)) event.preventDefault();
    if (event.key === 'ArrowRight') focusNextTab();
    if (event.key === 'ArrowLeft') focusPreviousTab();
  } else {
    // Prevent vertical scroll
    if (['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) event.preventDefault();
    if (event.key === 'ArrowDown') focusNextTab();
    if (event.key === 'ArrowUp') focusPreviousTab();
  }
  if (event.key === 'End') focusLastTab();
  if (event.key === 'Home') focusLastTab();
}

这是很多代码,但不是很花哨。根据键盘导航,它将聚焦tablist的选项卡。

注意:我们不需要处理键盘选择,因为button已经触发了click和Spacebar上的click事件。

CSS

让我们让这个标签更奇特。

我通常在:root中设置一些自定义属性以具有连贯的样式。

要获得很棒的对比度,我将使用俄克利冰,因为对比度在不同的色调中保持一致。在背景和文字之间有80%的差异,我肯定会有良好的对比:

:root {
  --hue: 250; /* blue. change between 0 and 360 to have your favorite color */
  --primary: oklch(60% 0.3 var(--hue));
  --outline: oklch(60% 0 var(--hue));
  --background: oklch(98% 0.03 var(--hue));
  --text: oklch(20% 0.03 var(--hue));
  color-sheme: light dark;
  accent-color: var(--primary);
}

让我们建立黑暗模式:

@media (prefers-color-scheme: dark) {
  :root {
    --background: oklch(15% 0.03 var(--hue));
    --text: oklch(98% 0.03 var(--hue));
  }
}

我们只是更改--background--text,因为--primary--outline在这两种方案中都可以正常工作。

和一个默认主体:

body {
  height: 100dvh;
  font-family: system-ui;
  color: var(--text);
  background-color: var(--background);
}

选项卡

.tabs {
  display: flex;
  gap: 8px;
}
.tabs[aria-orientation="horizontal"] {
  flex-direction: column;
}
.tabs[aria-orientation="vertical"] {
  flex-direction: row;
}

不需要设置flex-direction: row,因为这是默认值,但是对于本文,我将被明确。

小表

[role="tablist"] {
  display: flex;
  padding: 4px 8px;
  gap: 8px;
  overflow: auto;
  scroll-behavior: smooth;
  --tablist-scroll-width: 4px;
  &::-webkit-scrollbar-track {
    background: oklch(80% 0 var(--hue));
  }
  &::-webkit-scrollbar-thumb {
    background: oklch(60% 0 var(--hue));
  }
}
.tabs[aria-orientation="horizontal"] [role="tablist"] {
  flex-direction: row;
  &::-webkit-scrollbar {
    height: var(--tablist-scroll-width, 4px);
  }
}
.tabs[aria-orientation="vertical"] [role="tablist"] {
  flex-direction: column;
  &::-webkit-scrollbar {
    width: var(--tablist-scroll-width, 4px);
  }
}

我正在使用[role="tablist"]作为选择器。我认为它对演示效果很好,但是您可能需要在组件库中使用类来避免冲突。

卷轴
如果标签溢出,您想拥有一个不错的滚动条,或者会破坏体验。我已经使用了本地自定义属性--tablist-scroll-width在水平和垂直模式下轻松更新它。

标签

我们需要一个选定选项卡的指示器。为之

基本样式:

[role="tab"] {
  flex-shrink: 0;
  cursor: pointer;
  color: currentcolor;
  background-color: transparent;
  border: none;
  border-radius: 4px;
  padding: 16px;
}
.tabs[aria-orientation="horizontal"] [role="tab"] {
  text-align: center;
}
.tabs[aria-orientation="vertical"] [role="tab"] {
  text-align: start;
}

我们使用flex-shrink:0在需要的情况下让tab溢出容器(太多标签,小屏幕,...)。

互动

[role="tab"]:hover {
  background-color: color-mix(in oklch, var(--text) 12%, var(--background));
}
[role="tab"]:active {
  background-color: color-mix(in oklch, var(--text) 24%, var(--background));
}
[role="tab"]:focus-visible {
  outline: solid 1px var(--primary);
}

这是一个小技巧。我们使用color-mix进行管理:悬停&:活动颜色。通过混合--background--text,我们确定它可以在光和黑暗方案中起作用。
通过将alpha设置为12%和24%的用户将感觉到互动性而不会破坏对比度。
对于焦点,我使用--primary在所有浏览器中获得一致的体验(Firefox默认使用accent-color,但不能使用Chrome)。

选定选项卡

[role="tab"][aria-selected="true"] {
  position: relative;
}
[role="tab"][aria-selected="true"]::before {
  content: "";
  position: absolute;
  border-radius: 4px;
  background-color: var(--primary);
}
.tabs[aria-orientation="horizontal"] [role="tab"][aria-selected="true"]::before {
  inset-block-end: -2px;
  inset-inline: 0;
  height: 1px;
}
.tabs[aria-orientation="vertical"] [role="tab"][aria-selected="true"]::before {
  inset-inline-end: -2px;
  inset-block: 0;
  width: 1px;
}

对于“选定”选项卡,我们将在tablisttabpanel之间显示一条线。我们使用inset-inline-end而不是left / right来管理dir="rtl"dir="ltr"无媒体查询。< / p>

TABPANEL

这个很容易

[role="tabpanel"]:not([hidden]) {
  flex: 1;
  display: grid;
  place-items: center;
}

我们只想针对没有隐藏的tabpanel,因为我们设置了display

动画片

现在是有趣的部分。对于动画,我们将使用View Transition API。目前仅在铬浏览器上工作

标签

我们想在选项卡之间移动下划线,因为我们需要在::before pseudo-lement上设置view-transition-name

[role="tab"][aria-selected="true"]::before {
  ...
  view-transition-name: selected-tab;
}

â!¸应该只有一个元素,具有相同的view-transition-name pre框架。这就是为什么我们仅在aria-selected选项卡上进行设置。

现在我们需要告诉浏览器何时运行过渡。在我们的情况下,它是在select函数期间:

function select(event) {
  ...
  // Move all DOM update into a closure
  function updateDOM() {
    previousTab.setAttribute('aria-selected', 'false');
    previousTab.setAttribute('tabindex', '-1');
    previousPanel.setAttribute('hidden', '');
    nextTab.setAttribute('aria-selected', 'true');
    nextTab.setAttribute('tabindex', '0');
    nextPanel.removeAttribute('hidden');
    nextTab.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
    })
  }
  // If document support view transition api
  if ('startViewTransition' in document) {
    document.startViewTransition(updateDOM);
  } else {
    updateDOM();
  }
}

尝试一下!

现在,让我们将第二个标签文本更改为更长的东西:

<button id="tab-2" ...>
  Super long label for a tab
</button>

然后,动画看起来很奇怪。这是因为default transition
原因是因为“它将维持其纵横比而不是填充小组”。因此,如果标签的宽度不同,则动画将保持前一个的比率。
要解决此问题,我们需要更新CSS:

::view-transition-new(selected-tab),
::view-transition-old(selected-tab){
  height: 100%;
}

这样,选项卡将不会尝试保持长宽比。

为了改善过渡,我们可以更改动画放松,因为我们需要针对::view-transition-group(selected-tab),因为它是负责变化的一个。

::view-transition-group(selected-tab) {
  animation-duration: 0.5s;
  animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}

和voilã!您有一个精美的标签动画。

gotchas

  • 如果选定选项卡不在小表格之外,则可以看到,因为视图过渡会在body之外创建元素。
  • 如果您的应用程序中有多个选项卡组件,则无法使用此方法,因为您有几次相同的view-transition-name。有一个解决方案,但我不会在本文中讨论。

TABPANEL

几乎在那里!现在,我们需要对进入和退出面板进行动画动画。让我们做一个幻灯片效果。首先让在可见面板上添加一个view-transition-name

[role="tabpanel"]:not([hidden]) {
  ...
  view-transition-name: selected-panel;
}

为此,我们需要知道面板的顺序。为此,我们将使用compareDocumentPosition并使用Web Animation API触发动画:

  if ('startViewTransition' in document) {
    // Animate tabs
    const transition = document.startViewTransition(updateDOM);

    // Animate panels
    await transition.ready;
    // Get animation orientation
    const translate = root.getAttribute('aria-orientation') === 'horizontal'
      ? 'translateX'
      : 'translateY';
    // Check order of panels in the DOM
    const dir = previousPanel?.compareDocumentPosition(nextPanel) === Node.DOCUMENT_POSITION_FOLLOWING
      ? -1
      : 1;

    // Animation happen on the document element
    document.documentElement.animate({
      transform: [
        `${translate}(0)`,
        `${translate}(${100 * dir}%)`
      ]
    },{
      duration: 500,
      easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
      // Target the panel leaving
      pseudoElement: '::view-transition-old(selected-panel)',
    });

    document.documentElement.animate({
      transform: [
        `${translate}(${-100 * dir}%)`,
        `${translate}(0)`
      ]
    },{
      duration: 500,
      easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
      // Target the panel entering
      pseudoElement: '::view-transition-new(selected-panel)',
    });
  }

真棒!但是现在面板进入并离开盒子外。为了防止这种情况,我们需要在::view-transition-group(selected-panel)上设置溢出:

::view-transition-group(selected-panel) {
  overflow: hidden;
  animation-duration: 0.5s;
}
::view-transition-new(selected-panel),
::view-transition-old(selected-panel){
  animation: none;
}

我还删除了new&old上的默认动画以避免淡出效果,如果您喜欢它,可以保留它。

结论

终于!您不认为这需要这么多的工作才能完成一个简单的标签吗?
我希望你喜欢它。我将使用其他组件进行视图过渡API的实验,因此请保持调整!