From ecfb764c6bf019040efe21503001b4f02db1f838 Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Thu, 22 Sep 2022 18:50:14 +0300 Subject: [PATCH 1/9] a11y: support Tabs, TabsItem accessibilty --- src/components/Group/Group.tsx | 21 ++++++ src/components/Tabs/Readme.md | 34 ++++++++- src/components/Tabs/Tabs.tsx | 106 ++++++++++++++++++++++++++- src/components/TabsItem/TabsItem.tsx | 16 ++++ src/lib/accessibility.ts | 30 ++++++++ 5 files changed, 203 insertions(+), 4 deletions(-) diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 3408520fb2..cafe577395 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -7,6 +7,7 @@ import { Spacing } from "../Spacing/Spacing"; import { Separator } from "../Separator/Separator"; import { hasReactNode } from "../../lib/utils"; import { Caption } from "../Typography/Caption/Caption"; +import { warnOnce } from "../../lib/warnOnce"; import { withAdaptivity, AdaptivityProps, @@ -41,6 +42,8 @@ export interface GroupProps children?: React.ReactNode; } +const warn = warnOnce("TabsItem"); + const GroupComponent = ({ header, description, @@ -62,6 +65,23 @@ const GroupComponent = ({ sizeX === SizeType.COMPACT || isInsideModal ? "plain" : "card"; } + const isTabPanel = restProps.role === "tabpanel"; + + if ( + process.env.NODE_ENV === "development" && + isTabPanel && + (!restProps["aria-controls"] || !restProps["id"]) + ) { + warn( + 'При использовани роли "tabpanel" необходимо задать значение пропов "aria-controls" и "id"' + ); + } + + let tabIndex = restProps.tabIndex; + if (isTabPanel && tabIndex === undefined) { + tabIndex = 0; + } + let separatorElement = null; if (separator !== "hide") { @@ -80,6 +100,7 @@ const GroupComponent = ({ return (
+Управляемый табом элемент должен содержать параметры `id` и `aria-controls`, ссылающийся на его таб, а также `tabIndex = 0`. + ```jsx const Example = ({ sizeX }) => { const [mode, setMode] = React.useState("all"); @@ -22,6 +27,8 @@ const Example = ({ sizeX }) => { separator={sizeX === SizeType.REGULAR} > { setMenuOpened((prevState) => (opened ? !prevState : false)); @@ -29,6 +36,25 @@ const Example = ({ sizeX }) => { /> + {selected === "news" && ( + +
Контент новостей
+
+ )} + {selected === "recommendations" && ( + +
Контент рекомендаций
+
+ )} + { ); }; -const DefaultInPanel = ({ menuOpened, onMenuClick }) => { - const [selected, setSelected] = React.useState("news"); - +const DefaultInPanel = ({ menuOpened, onMenuClick, selected, setSelected }) => { return ( { } setSelected("news"); }} + id="tab-news" + aria-controls="tab-content-news" > Новости @@ -94,6 +120,8 @@ const DefaultInPanel = ({ menuOpened, onMenuClick }) => { onMenuClick(false); setSelected("recommendations"); }} + id="tab-recommendations" + aria-controls="tab-content-recommendations" > Интересное diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 84b8134800..923f1d2294 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -5,6 +5,9 @@ import { usePlatform } from "../../hooks/usePlatform"; import { IOS, VKCOM } from "../../lib/platform"; import { withAdaptivity, AdaptivityProps } from "../../hoc/withAdaptivity"; import { warnOnce } from "../../lib/warnOnce"; +import { useGlobalEventListener } from "../../hooks/useGlobalEventListener"; +import { useDOM } from "../../lib/dom"; +import { pressedKey } from "../../lib/accessibility"; import "./Tabs.css"; export interface TabsProps @@ -41,6 +44,9 @@ const TabsComponent = ({ ...restProps }: TabsProps) => { const platform = usePlatform(); + const { document } = useDOM(); + + const tabsRef = React.useRef(); if ( (mode === "buttons" || mode === "segmented") && @@ -65,6 +71,103 @@ const TabsComponent = ({ const withGaps = mode === "accent" || mode === "secondary"; + function getTabEls(): HTMLDivElement[] { + if (!tabsRef.current) { + return []; + } + + return Array.from( + // eslint-disable-next-line + tabsRef.current.querySelectorAll("[role=tab]") + ); + } + + function onDocumentKeydown(e: KeyboardEvent) { + if (!document || !tabsRef.current) { + return; + } + + const key = pressedKey(e); + + switch (key) { + case "ArrowLeft": + case "ArrowRight": + case "End": + case "Home": { + const tabEls = getTabEls(); + const currentFocusedElIndex = tabEls.findIndex( + (el) => document.activeElement === el + ); + if (currentFocusedElIndex === -1) { + return; + } + + let nextIndex = 0; + if (key === "Home") { + nextIndex = 0; + } else if (key === "End") { + nextIndex = tabEls.length - 1; + } else { + const offset = key === "ArrowRight" ? 1 : -1; + nextIndex = currentFocusedElIndex + offset; + } + + const nextTabEl = tabEls[nextIndex]; + + if (nextTabEl) { + e.preventDefault(); + nextTabEl.focus(); + } + + break; + } + /* + В JAWS и NVDA стрелка вниз активирует контент. + Это не прописано в стандартах, но по ссылке ниже это рекомендуется делать. + https://inclusive-components.design/tabbed-interfaces/ + */ + case "ArrowDown": { + const tabEls = getTabEls(); + const currentFocusedEl = tabEls.find( + (el) => document.activeElement === el + ); + + if (!currentFocusedEl || currentFocusedEl.getAttribute('aria-selected') !== 'true') { + return; + } + + const relatedContentElId = currentFocusedEl.getAttribute('aria-controls'); + if (!relatedContentElId) { + return; + } + + const relatedContentEl = document.querySelector('#' + relatedContentElId) as HTMLElement; + if (!relatedContentEl) { + return; + } + + e.preventDefault(); + relatedContentEl.focus(); + + break; + } + case "Space": + case "Enter": { + const tabEls = getTabEls(); + const currentFocusedEl = tabEls.find( + (el) => document.activeElement === el + ); + if (currentFocusedEl) { + currentFocusedEl.click(); + } + } + } + } + + useGlobalEventListener(document, "keydown", onDocumentKeydown, { + capture: true, + }); + return (
-
+
(tabsRef.current = el)}> {children} diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index ada964740b..296705b612 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -7,6 +7,8 @@ import { useAdaptivity } from "../../hooks/useAdaptivity"; import { TabsModeContext, TabsContextProps } from "../Tabs/Tabs"; import { Headline } from "../Typography/Headline/Headline"; import { Subhead } from "../Typography/Subhead/Subhead"; +import { Text } from "../Typography/Text/Text"; +import { warnOnce } from "../../lib/warnOnce"; import "./TabsItem.css"; export interface TabsItemProps extends React.HTMLAttributes { @@ -35,6 +37,8 @@ export interface TabsItemProps extends React.HTMLAttributes { disabled?: boolean; } +const warn = warnOnce("TabsItem"); + /** * @see https://vkcom.github.io/VKUI/#/TabsItem */ @@ -67,6 +71,15 @@ export const TabsItem = ({ ); } + if (process.env.NODE_ENV === "development" && !restProps["aria-controls"]) { + warn(`Передайте в "aria-controls" id контролируемого блока`, "warn"); + } else if (process.env.NODE_ENV === "development" && !restProps["id"]) { + warn( + `Передайте "id" компоненту для использования в "aria-labelledby" контролируемого блока`, + "warn" + ); + } + return ( {before &&
{before}
} Date: Thu, 22 Sep 2022 19:21:28 +0300 Subject: [PATCH 2/9] add Tabs keyboard unit tests --- src/components/FocusTrap/FocusTrap.tsx | 6 +- src/components/Tabs/Tabs.test.tsx | 191 +++++++++++++++++++++++++ src/components/Tabs/Tabs.tsx | 4 +- src/lib/accessibility.ts | 1 + 4 files changed, 196 insertions(+), 6 deletions(-) diff --git a/src/components/FocusTrap/FocusTrap.tsx b/src/components/FocusTrap/FocusTrap.tsx index 6abed361ca..737b0f96e3 100644 --- a/src/components/FocusTrap/FocusTrap.tsx +++ b/src/components/FocusTrap/FocusTrap.tsx @@ -3,7 +3,7 @@ import { useExternRef } from "../../hooks/useExternRef"; import { useGlobalEventListener } from "../../hooks/useGlobalEventListener"; import { useTimeout } from "../../hooks/useTimeout"; import { - FOCUSABLE_ELEMENTS_LIST, + FOCUSABLE_ELEMENTS_QUERY, Keys, pressedKey, } from "../../lib/accessibility"; @@ -12,8 +12,6 @@ import { useIsomorphicLayoutEffect } from "../../lib/useIsomorphicLayoutEffect"; import { HasComponent, HasRootRef } from "../../types"; import { AppRootContext } from "../AppRoot/AppRootContext"; -const FOCUSABLE_ELEMENTS: string = FOCUSABLE_ELEMENTS_LIST.join(); - export interface FocusTrapProps extends React.AllHTMLAttributes, HasRootRef, @@ -71,7 +69,7 @@ export const FocusTrap = ({ const nodes: HTMLElement[] = []; Array.prototype.forEach.call( // eslint-disable-next-line no-restricted-properties - ref.current.querySelectorAll(FOCUSABLE_ELEMENTS), + ref.current.querySelectorAll(FOCUSABLE_ELEMENTS_QUERY), (focusableEl: Element) => { const { display, visibility } = window!.getComputedStyle(focusableEl); diff --git a/src/components/Tabs/Tabs.test.tsx b/src/components/Tabs/Tabs.test.tsx index ab6814421d..99b3129006 100644 --- a/src/components/Tabs/Tabs.test.tsx +++ b/src/components/Tabs/Tabs.test.tsx @@ -1,6 +1,197 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { TabsItem } from "../TabsItem/TabsItem"; import { baselineComponent } from "../../testing/utils"; import { Tabs } from "./Tabs"; +import { Group } from "../Group/Group"; +import { ComponentProps, useState } from "react"; + +function TestTabs(props: { disabledKeys?: string[] }) { + const [currentTab, setCurrentTab] = useState("first"); + + return ( +
+ + setCurrentTab("first")} + aria-controls="tab-content-first" + disabled={props.disabledKeys?.includes("first")} + > + First + + setCurrentTab("second")} + selected={currentTab === "second"} + disabled={props.disabledKeys?.includes("second")} + > + Second + + setCurrentTab("third")} + selected={currentTab === "third"} + disabled={props.disabledKeys?.includes("third")} + > + Third + + + {currentTab === "first" && ( + + )} + {currentTab === "second" && ( + + )} + {currentTab === "third" && ( + + )} +
+ ); +} + +function isTabSelected(el: HTMLElement) { + return el.getAttribute("aria-selected") === "true"; +} + +function isTabFocused(el: HTMLElement) { + return document.activeElement === el; +} + +function renderTestTabs(props: ComponentProps = {}) { + render(); + screen.getByTestId("first").focus(); + screen.getByTestId("first").click(); + expect(screen.getByTestId("first").getAttribute("aria-selected")).toEqual( + "true" + ); + expect(isTabSelected(screen.getByTestId("first"))).toBeTruthy(); +} + +function pressKey(key: string) { + if (!document.activeElement) { + return; + } + + fireEvent.keyDown(document.activeElement, { + key, + }); +} describe("Tabs", () => { baselineComponent(Tabs); + + describe("Mouse handlers", () => { + it("select element on click", () => { + renderTestTabs(); + + fireEvent.click(screen.getByTestId("third")); + + expect(screen.getByTestId("third").getAttribute("aria-selected")).toEqual( + "true" + ); + expect(isTabSelected(screen.getByTestId("third"))).toBeTruthy(); + }); + it("doesn't select disabled element on click", () => { + renderTestTabs({ disabledKeys: ["third"] }); + + fireEvent.click(screen.getByTestId("third")); + + expect(isTabSelected(screen.getByTestId("third"))).toBeFalsy(); + }); + }); + + describe("Keyboard handlers", () => { + it("doesn't focus previous element when first focused", () => { + renderTestTabs(); + screen.getByTestId("first").focus(); + pressKey("ArrowLeft"); + expect(isTabFocused(screen.getByTestId("first"))).toBeTruthy(); + }); + it("doesn't focus next element when last focused", () => { + renderTestTabs(); + screen.getByTestId("third").focus(); + pressKey("ArrowRight"); + expect(isTabFocused(screen.getByTestId("third"))).toBeTruthy(); + }); + it("focus next element with ArrowRight key", () => { + renderTestTabs(); + screen.getByTestId("second").focus(); + pressKey("ArrowRight"); + expect(isTabFocused(screen.getByTestId("third"))).toBeTruthy(); + }); + it("focus previuos element with ArrowLeft key", () => { + renderTestTabs(); + screen.getByTestId("second").focus(); + pressKey("ArrowLeft"); + expect(isTabFocused(screen.getByTestId("first"))).toBeTruthy(); + }); + it("focus first element with Home key", () => { + renderTestTabs(); + screen.getByTestId("third").focus(); + pressKey("Home"); + expect(isTabFocused(screen.getByTestId("first"))).toBeTruthy(); + }); + it("focus last element with End key", () => { + renderTestTabs(); + screen.getByTestId("first").focus(); + pressKey("End"); + expect(isTabFocused(screen.getByTestId("third"))).toBeTruthy(); + }); + it("select element with Space key", () => { + renderTestTabs(); + screen.getByTestId("first").focus(); + pressKey("ArrowRight"); + pressKey("Space"); + expect(isTabFocused(screen.getByTestId("second"))).toBeTruthy(); + expect(isTabSelected(screen.getByTestId("second"))).toBeTruthy(); + }); + it("select element with Enter key", () => { + renderTestTabs(); + screen.getByTestId("first").focus(); + pressKey("ArrowRight"); + pressKey("Enter"); + expect(isTabFocused(screen.getByTestId("second"))).toBeTruthy(); + expect(isTabSelected(screen.getByTestId("second"))).toBeTruthy(); + }); + it("skip disabled elements", () => { + renderTestTabs({ disabledKeys: ["second"] }); + screen.getByTestId("first").focus(); + pressKey("ArrowRight"); + pressKey("Enter"); + expect(isTabFocused(screen.getByTestId("third"))).toBeTruthy(); + expect(isTabSelected(screen.getByTestId("second"))).toBeFalsy(); + expect(isTabSelected(screen.getByTestId("third"))).toBeTruthy(); + }); + it("focus content with Down key", () => { + renderTestTabs(); + screen.getByTestId("second").focus(); + pressKey("Enter"); + pressKey("ArrowDown"); + expect(isTabSelected(screen.getByTestId("second"))).toBeTruthy(); + expect(document.activeElement).toEqual( + screen.getByTestId("content-second") + ); + }); + }); }); diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 923f1d2294..c51708e3c2 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -7,7 +7,7 @@ import { withAdaptivity, AdaptivityProps } from "../../hoc/withAdaptivity"; import { warnOnce } from "../../lib/warnOnce"; import { useGlobalEventListener } from "../../hooks/useGlobalEventListener"; import { useDOM } from "../../lib/dom"; -import { pressedKey } from "../../lib/accessibility"; +import { FOCUSABLE_ELEMENTS_QUERY, pressedKey } from "../../lib/accessibility"; import "./Tabs.css"; export interface TabsProps @@ -78,7 +78,7 @@ const TabsComponent = ({ return Array.from( // eslint-disable-next-line - tabsRef.current.querySelectorAll("[role=tab]") + tabsRef.current.querySelectorAll("[role=tab]:not([disabled])") ); } diff --git a/src/lib/accessibility.ts b/src/lib/accessibility.ts index f9a590d37b..2c1bd2d364 100644 --- a/src/lib/accessibility.ts +++ b/src/lib/accessibility.ts @@ -13,6 +13,7 @@ export const FOCUSABLE_ELEMENTS_LIST = [ "[contenteditable]", '[tabindex]:not([tabindex="-1"])', ]; +export const FOCUSABLE_ELEMENTS_QUERY: string = FOCUSABLE_ELEMENTS_LIST.join(); export enum Keys { ENTER = "Enter", From 28f9ebb2a6aa557cc966f483c9c0f9c407f76e06 Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Thu, 22 Sep 2022 20:13:31 +0300 Subject: [PATCH 3/9] remove unused import --- src/components/FocusTrap/FocusTrap.tsx | 5 +++-- src/components/Tabs/Tabs.tsx | 6 ++++-- src/components/TabsItem/TabsItem.tsx | 1 - src/lib/accessibility.ts | 1 - 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/FocusTrap/FocusTrap.tsx b/src/components/FocusTrap/FocusTrap.tsx index 737b0f96e3..ee00359d3a 100644 --- a/src/components/FocusTrap/FocusTrap.tsx +++ b/src/components/FocusTrap/FocusTrap.tsx @@ -3,7 +3,7 @@ import { useExternRef } from "../../hooks/useExternRef"; import { useGlobalEventListener } from "../../hooks/useGlobalEventListener"; import { useTimeout } from "../../hooks/useTimeout"; import { - FOCUSABLE_ELEMENTS_QUERY, + FOCUSABLE_ELEMENTS_LIST, Keys, pressedKey, } from "../../lib/accessibility"; @@ -12,6 +12,7 @@ import { useIsomorphicLayoutEffect } from "../../lib/useIsomorphicLayoutEffect"; import { HasComponent, HasRootRef } from "../../types"; import { AppRootContext } from "../AppRoot/AppRootContext"; +const FOCUSABLE_ELEMENTS: string = FOCUSABLE_ELEMENTS_LIST.join(); export interface FocusTrapProps extends React.AllHTMLAttributes, HasRootRef, @@ -69,7 +70,7 @@ export const FocusTrap = ({ const nodes: HTMLElement[] = []; Array.prototype.forEach.call( // eslint-disable-next-line no-restricted-properties - ref.current.querySelectorAll(FOCUSABLE_ELEMENTS_QUERY), + ref.current.querySelectorAll(FOCUSABLE_ELEMENTS), (focusableEl: Element) => { const { display, visibility } = window!.getComputedStyle(focusableEl); diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index c51708e3c2..05273e8fd0 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -7,7 +7,7 @@ import { withAdaptivity, AdaptivityProps } from "../../hoc/withAdaptivity"; import { warnOnce } from "../../lib/warnOnce"; import { useGlobalEventListener } from "../../hooks/useGlobalEventListener"; import { useDOM } from "../../lib/dom"; -import { FOCUSABLE_ELEMENTS_QUERY, pressedKey } from "../../lib/accessibility"; +import { pressedKey } from "../../lib/accessibility"; import "./Tabs.css"; export interface TabsProps @@ -78,7 +78,9 @@ const TabsComponent = ({ return Array.from( // eslint-disable-next-line - tabsRef.current.querySelectorAll("[role=tab]:not([disabled])") + tabsRef.current.querySelectorAll( + "[role=tab]:not([disabled])" + ) ); } diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index 296705b612..bf6d26e53c 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -7,7 +7,6 @@ import { useAdaptivity } from "../../hooks/useAdaptivity"; import { TabsModeContext, TabsContextProps } from "../Tabs/Tabs"; import { Headline } from "../Typography/Headline/Headline"; import { Subhead } from "../Typography/Subhead/Subhead"; -import { Text } from "../Typography/Text/Text"; import { warnOnce } from "../../lib/warnOnce"; import "./TabsItem.css"; diff --git a/src/lib/accessibility.ts b/src/lib/accessibility.ts index 2c1bd2d364..f9a590d37b 100644 --- a/src/lib/accessibility.ts +++ b/src/lib/accessibility.ts @@ -13,7 +13,6 @@ export const FOCUSABLE_ELEMENTS_LIST = [ "[contenteditable]", '[tabindex]:not([tabindex="-1"])', ]; -export const FOCUSABLE_ELEMENTS_QUERY: string = FOCUSABLE_ELEMENTS_LIST.join(); export enum Keys { ENTER = "Enter", From a4be4f68eac56c2d70f0f98b62ede0fa9674eb00 Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Mon, 26 Sep 2022 16:45:07 +0300 Subject: [PATCH 4/9] review fixes --- src/components/Tabs/Readme.md | 2 +- src/components/Tabs/Tabs.tsx | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/Tabs/Readme.md b/src/components/Tabs/Readme.md index cd08de6ac1..e4e0e6fe08 100644 --- a/src/components/Tabs/Readme.md +++ b/src/components/Tabs/Readme.md @@ -1,6 +1,6 @@ ## Доступность -Чтобы скриридеры понимали, каким элементом управляет `TabsItem`, ему нужно задать `id`, и ссылаться на него в управляемом элементе с помощью `aria-labelledby`
+Чтобы скринридеры понимали, каким элементом управляет `TabsItem`, ему нужно задать `id` и сослаться на него в управляемом элементе с помощью `aria-labelledby`.
Управляемый табом элемент должен содержать параметры `id` и `aria-controls`, ссылающийся на его таб, а также `tabIndex = 0`. ```jsx diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 05273e8fd0..480a8520d7 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -134,16 +134,23 @@ const TabsComponent = ({ (el) => document.activeElement === el ); - if (!currentFocusedEl || currentFocusedEl.getAttribute('aria-selected') !== 'true') { + if ( + !currentFocusedEl || + currentFocusedEl.getAttribute("aria-selected") !== "true" + ) { return; } - const relatedContentElId = currentFocusedEl.getAttribute('aria-controls'); + const relatedContentElId = + currentFocusedEl.getAttribute("aria-controls"); if (!relatedContentElId) { return; } - const relatedContentEl = document.querySelector('#' + relatedContentElId) as HTMLElement; + // eslint-disable-next-line no-restricted-properties + const relatedContentEl = document.querySelector( + "#" + relatedContentElId + ) as HTMLElement; if (!relatedContentEl) { return; } From 7f2026541e6ad15ba5b54394dc04175696650228 Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Thu, 6 Oct 2022 17:09:24 +0300 Subject: [PATCH 5/9] review fixes --- src/components/Group/Group.tsx | 6 ++---- src/components/Tabs/Readme.md | 7 +++++-- src/components/Tabs/Tabs.test.tsx | 7 ------- src/components/Tabs/Tabs.tsx | 18 ++++++++---------- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index cafe577395..2fbf86b326 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -77,10 +77,8 @@ const GroupComponent = ({ ); } - let tabIndex = restProps.tabIndex; - if (isTabPanel && tabIndex === undefined) { - tabIndex = 0; - } + const tabIndex = + isTabPanel && restProps.tabIndex === undefined ? 0 : restProps.tabIndex; let separatorElement = null; diff --git a/src/components/Tabs/Readme.md b/src/components/Tabs/Readme.md index e4e0e6fe08..cc7db1fb2e 100644 --- a/src/components/Tabs/Readme.md +++ b/src/components/Tabs/Readme.md @@ -1,7 +1,10 @@ ## Доступность -Чтобы скринридеры понимали, каким элементом управляет `TabsItem`, ему нужно задать `id` и сослаться на него в управляемом элементе с помощью `aria-labelledby`.
-Управляемый табом элемент должен содержать параметры `id` и `aria-controls`, ссылающийся на его таб, а также `tabIndex = 0`. +Для корректной работы скринридеров необходимо вручную передавать некоторые параметры: +
+ +- В компонент вкладки (`TabsItem`) нужно передать `id` и `aria-controls`, указывающий на id области с его контентом.
+- В область контента необходимо передать параметры `id`, `tabIndex = 0` и `aria-labelledby`, ссылающийся на компонент таба ```jsx const Example = ({ sizeX }) => { diff --git a/src/components/Tabs/Tabs.test.tsx b/src/components/Tabs/Tabs.test.tsx index 99b3129006..34b2fe81f3 100644 --- a/src/components/Tabs/Tabs.test.tsx +++ b/src/components/Tabs/Tabs.test.tsx @@ -82,10 +82,6 @@ function renderTestTabs(props: ComponentProps = {}) { render(); screen.getByTestId("first").focus(); screen.getByTestId("first").click(); - expect(screen.getByTestId("first").getAttribute("aria-selected")).toEqual( - "true" - ); - expect(isTabSelected(screen.getByTestId("first"))).toBeTruthy(); } function pressKey(key: string) { @@ -107,9 +103,6 @@ describe("Tabs", () => { fireEvent.click(screen.getByTestId("third")); - expect(screen.getByTestId("third").getAttribute("aria-selected")).toEqual( - "true" - ); expect(isTabSelected(screen.getByTestId("third"))).toBeTruthy(); }); it("doesn't select disabled element on click", () => { diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 480a8520d7..45aeac7f60 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -46,7 +46,7 @@ const TabsComponent = ({ const platform = usePlatform(); const { document } = useDOM(); - const tabsRef = React.useRef(); + const tabsRef = React.useRef(null); if ( (mode === "buttons" || mode === "segmented") && @@ -84,12 +84,12 @@ const TabsComponent = ({ ); } - function onDocumentKeydown(e: KeyboardEvent) { + function handleDocumentKeydown(event: KeyboardEvent) { if (!document || !tabsRef.current) { return; } - const key = pressedKey(e); + const key = pressedKey(event); switch (key) { case "ArrowLeft": @@ -117,7 +117,7 @@ const TabsComponent = ({ const nextTabEl = tabEls[nextIndex]; if (nextTabEl) { - e.preventDefault(); + event.preventDefault(); nextTabEl.focus(); } @@ -148,14 +148,12 @@ const TabsComponent = ({ } // eslint-disable-next-line no-restricted-properties - const relatedContentEl = document.querySelector( - "#" + relatedContentElId - ) as HTMLElement; + const relatedContentEl = document.getElementById(relatedContentElId); if (!relatedContentEl) { return; } - e.preventDefault(); + event.preventDefault(); relatedContentEl.focus(); break; @@ -173,7 +171,7 @@ const TabsComponent = ({ } } - useGlobalEventListener(document, "keydown", onDocumentKeydown, { + useGlobalEventListener(document, "keydown", handleDocumentKeydown, { capture: true, }); @@ -191,7 +189,7 @@ const TabsComponent = ({ )} role="tablist" > -
(tabsRef.current = el)}> +
{children} From 5678888c8bf43e9f5679368cb1ce711bb995f111 Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Thu, 6 Oct 2022 18:33:28 +0300 Subject: [PATCH 6/9] disable tabflow if tab has different role --- src/components/Tabs/Tabs.tsx | 7 +++++-- src/components/TabsItem/TabsItem.tsx | 28 +++++++++++++++++++--------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 45aeac7f60..5852e10eb5 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -46,6 +46,9 @@ const TabsComponent = ({ const platform = usePlatform(); const { document } = useDOM(); + const role = restProps.role || "tablist"; + const isTabFlow = role === "tablist"; + const tabsRef = React.useRef(null); if ( @@ -85,7 +88,7 @@ const TabsComponent = ({ } function handleDocumentKeydown(event: KeyboardEvent) { - if (!document || !tabsRef.current) { + if (!document || !tabsRef.current || !isTabFlow) { return; } @@ -187,7 +190,7 @@ const TabsComponent = ({ // TODO v5.0.0 новая адаптивность `Tabs--sizeX-${sizeX}` )} - role="tablist" + role={role} >
diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index bf6d26e53c..4af010d551 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -55,6 +55,9 @@ export const TabsItem = ({ React.useContext(TabsModeContext); let statusComponent = null; + const role = restProps.role || "tab"; + const isTabFlow = role === "tab"; + if (status) { statusComponent = typeof status === "number" ? ( @@ -70,13 +73,20 @@ export const TabsItem = ({ ); } - if (process.env.NODE_ENV === "development" && !restProps["aria-controls"]) { - warn(`Передайте в "aria-controls" id контролируемого блока`, "warn"); - } else if (process.env.NODE_ENV === "development" && !restProps["id"]) { - warn( - `Передайте "id" компоненту для использования в "aria-labelledby" контролируемого блока`, - "warn" - ); + if (process.env.NODE_ENV === "development" && isTabFlow) { + if (!restProps["aria-controls"]) { + warn(`Передайте в "aria-controls" id контролируемого блока`, "warn"); + } else if (!restProps["id"]) { + warn( + `Передайте "id" компоненту для использования в "aria-labelledby" контролируемого блока`, + "warn" + ); + } + } + + let tabIndex: React.HTMLAttributes["tabIndex"] = undefined; + if (isTabFlow) { + tabIndex = selected ? 0 : -1; } return ( @@ -95,9 +105,9 @@ export const TabsItem = ({ activeMode="TabsItem--active" focusVisibleMode={mode === "segmented" ? "outside" : "inside"} hasActive={mode === "segmented"} - role="tab" + role={restProps.role || "tab"} aria-selected={selected} - tabIndex={selected ? 0 : -1} + tabIndex={tabIndex} > {before &&
{before}
} Date: Thu, 6 Oct 2022 19:55:03 +0300 Subject: [PATCH 7/9] review fixes --- src/components/Tabs/Tabs.tsx | 10 +++++----- src/components/TabsItem/TabsItem.tsx | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 5852e10eb5..4197b58d51 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -41,12 +41,12 @@ const TabsComponent = ({ mode = "default", getRootRef, sizeX, + role = "tablist", ...restProps }: TabsProps) => { const platform = usePlatform(); const { document } = useDOM(); - const role = restProps.role || "tablist"; const isTabFlow = role === "tablist"; const tabsRef = React.useRef(null); @@ -74,7 +74,7 @@ const TabsComponent = ({ const withGaps = mode === "accent" || mode === "secondary"; - function getTabEls(): HTMLDivElement[] { + const getTabEls = () => { if (!tabsRef.current) { return []; } @@ -85,9 +85,9 @@ const TabsComponent = ({ "[role=tab]:not([disabled])" ) ); - } + }; - function handleDocumentKeydown(event: KeyboardEvent) { + const handleDocumentKeydown = (event: KeyboardEvent) => { if (!document || !tabsRef.current || !isTabFlow) { return; } @@ -172,7 +172,7 @@ const TabsComponent = ({ } } } - } + }; useGlobalEventListener(document, "keydown", handleDocumentKeydown, { capture: true, diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index 4af010d551..a6e15d86d3 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -47,6 +47,7 @@ export const TabsItem = ({ status, after, selected = false, + role = "tab", ...restProps }: TabsItemProps) => { const platform = usePlatform(); @@ -55,7 +56,6 @@ export const TabsItem = ({ React.useContext(TabsModeContext); let statusComponent = null; - const role = restProps.role || "tab"; const isTabFlow = role === "tab"; if (status) { @@ -84,8 +84,9 @@ export const TabsItem = ({ } } - let tabIndex: React.HTMLAttributes["tabIndex"] = undefined; - if (isTabFlow) { + let tabIndex: React.HTMLAttributes["tabIndex"] = + restProps.tabIndex; + if (isTabFlow && tabIndex === undefined) { tabIndex = selected ? 0 : -1; } @@ -105,7 +106,7 @@ export const TabsItem = ({ activeMode="TabsItem--active" focusVisibleMode={mode === "segmented" ? "outside" : "inside"} hasActive={mode === "segmented"} - role={restProps.role || "tab"} + role={role} aria-selected={selected} tabIndex={tabIndex} > From 44a57073166368ad54dcee30907c38141eebfc6b Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Fri, 7 Oct 2022 12:05:46 +0300 Subject: [PATCH 8/9] review fixes --- src/components/Group/Group.tsx | 2 +- src/components/Tabs/Tabs.tsx | 2 +- src/components/TabsItem/TabsItem.tsx | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 2fbf86b326..0a70e50ab2 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -73,7 +73,7 @@ const GroupComponent = ({ (!restProps["aria-controls"] || !restProps["id"]) ) { warn( - 'При использовани роли "tabpanel" необходимо задать значение пропов "aria-controls" и "id"' + 'При использовании роли "tabpanel" необходимо задать значение свойств "aria-controls" и "id"' ); } diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 4197b58d51..85d4e2744a 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -80,7 +80,7 @@ const TabsComponent = ({ } return Array.from( - // eslint-disable-next-line + // eslint-disable-next-line no-restricted-properties tabsRef.current.querySelectorAll( "[role=tab]:not([disabled])" ) diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index a6e15d86d3..52f982c01b 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -84,8 +84,7 @@ export const TabsItem = ({ } } - let tabIndex: React.HTMLAttributes["tabIndex"] = - restProps.tabIndex; + let tabIndex = restProps.tabIndex; if (isTabFlow && tabIndex === undefined) { tabIndex = selected ? 0 : -1; } From e3f8610d2db5270a65ef9a8d53cebb2323a7460b Mon Sep 17 00:00:00 2001 From: Egor Smirnov Date: Fri, 7 Oct 2022 13:10:11 +0300 Subject: [PATCH 9/9] review fixes --- src/components/Group/Group.tsx | 4 ++-- src/components/TabsItem/TabsItem.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 0a70e50ab2..3ac1425af1 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -53,6 +53,7 @@ const GroupComponent = ({ mode, padding = "m", sizeX, + tabIndex: tabIndexProp, ...restProps }: GroupProps) => { const { isInsideModal } = React.useContext(ModalRootContext); @@ -77,8 +78,7 @@ const GroupComponent = ({ ); } - const tabIndex = - isTabPanel && restProps.tabIndex === undefined ? 0 : restProps.tabIndex; + const tabIndex = isTabPanel && tabIndexProp === undefined ? 0 : tabIndexProp; let separatorElement = null; diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index 52f982c01b..f623c2e54f 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -48,6 +48,7 @@ export const TabsItem = ({ after, selected = false, role = "tab", + tabIndex: tabIndexProp, ...restProps }: TabsItemProps) => { const platform = usePlatform(); @@ -84,7 +85,7 @@ export const TabsItem = ({ } } - let tabIndex = restProps.tabIndex; + let tabIndex = tabIndexProp; if (isTabFlow && tabIndex === undefined) { tabIndex = selected ? 0 : -1; }