让我们从头开始构建此选项卡组件:
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-selected
&tabindex
- 更改上一个和下一个面板上的
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;
}
对于“选定”选项卡,我们将在tablist
和tabpanel
之间显示一条线。我们使用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的实验,因此请保持调整!