diff --git a/src/components/FocusTrap/FocusTrap.tsx b/src/components/FocusTrap/FocusTrap.tsx index 6abed361ca..ee00359d3a 100644 --- a/src/components/FocusTrap/FocusTrap.tsx +++ b/src/components/FocusTrap/FocusTrap.tsx @@ -13,7 +13,6 @@ 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, diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 3408520fb2..3ac1425af1 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, @@ -50,6 +53,7 @@ const GroupComponent = ({ mode, padding = "m", sizeX, + tabIndex: tabIndexProp, ...restProps }: GroupProps) => { const { isInsideModal } = React.useContext(ModalRootContext); @@ -62,6 +66,20 @@ 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"' + ); + } + + const tabIndex = isTabPanel && tabIndexProp === undefined ? 0 : tabIndexProp; + let separatorElement = null; if (separator !== "hide") { @@ -80,6 +98,7 @@ const GroupComponent = ({ return (
+ +- В компонент вкладки (`TabsItem`) нужно передать `id` и `aria-controls`, указывающий на id области с его контентом.
+- В область контента необходимо передать параметры `id`, `tabIndex = 0` и `aria-labelledby`, ссылающийся на компонент таба + ```jsx const Example = ({ sizeX }) => { const [mode, setMode] = React.useState("all"); @@ -22,6 +30,8 @@ const Example = ({ sizeX }) => { separator={sizeX === SizeType.REGULAR} > { setMenuOpened((prevState) => (opened ? !prevState : false)); @@ -29,6 +39,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 +123,8 @@ const DefaultInPanel = ({ menuOpened, onMenuClick }) => { onMenuClick(false); setSelected("recommendations"); }} + id="tab-recommendations" + aria-controls="tab-content-recommendations" > Интересное diff --git a/src/components/Tabs/Tabs.test.tsx b/src/components/Tabs/Tabs.test.tsx index ab6814421d..34b2fe81f3 100644 --- a/src/components/Tabs/Tabs.test.tsx +++ b/src/components/Tabs/Tabs.test.tsx @@ -1,6 +1,190 @@ +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(); +} + +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(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 84b8134800..85d4e2744a 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 @@ -38,9 +41,15 @@ const TabsComponent = ({ mode = "default", getRootRef, sizeX, + role = "tablist", ...restProps }: TabsProps) => { const platform = usePlatform(); + const { document } = useDOM(); + + const isTabFlow = role === "tablist"; + + const tabsRef = React.useRef(null); if ( (mode === "buttons" || mode === "segmented") && @@ -65,6 +74,110 @@ const TabsComponent = ({ const withGaps = mode === "accent" || mode === "secondary"; + const getTabEls = () => { + if (!tabsRef.current) { + return []; + } + + return Array.from( + // eslint-disable-next-line no-restricted-properties + tabsRef.current.querySelectorAll( + "[role=tab]:not([disabled])" + ) + ); + }; + + const handleDocumentKeydown = (event: KeyboardEvent) => { + if (!document || !tabsRef.current || !isTabFlow) { + return; + } + + const key = pressedKey(event); + + 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) { + event.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; + } + + // eslint-disable-next-line no-restricted-properties + const relatedContentEl = document.getElementById(relatedContentElId); + if (!relatedContentEl) { + return; + } + + event.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", handleDocumentKeydown, { + capture: true, + }); + return (
-
+
{children} diff --git a/src/components/TabsItem/TabsItem.tsx b/src/components/TabsItem/TabsItem.tsx index ada964740b..f623c2e54f 100644 --- a/src/components/TabsItem/TabsItem.tsx +++ b/src/components/TabsItem/TabsItem.tsx @@ -7,6 +7,7 @@ import { useAdaptivity } from "../../hooks/useAdaptivity"; import { TabsModeContext, TabsContextProps } from "../Tabs/Tabs"; import { Headline } from "../Typography/Headline/Headline"; import { Subhead } from "../Typography/Subhead/Subhead"; +import { warnOnce } from "../../lib/warnOnce"; import "./TabsItem.css"; export interface TabsItemProps extends React.HTMLAttributes { @@ -35,6 +36,8 @@ export interface TabsItemProps extends React.HTMLAttributes { disabled?: boolean; } +const warn = warnOnce("TabsItem"); + /** * @see https://vkcom.github.io/VKUI/#/TabsItem */ @@ -44,6 +47,8 @@ export const TabsItem = ({ status, after, selected = false, + role = "tab", + tabIndex: tabIndexProp, ...restProps }: TabsItemProps) => { const platform = usePlatform(); @@ -52,6 +57,8 @@ export const TabsItem = ({ React.useContext(TabsModeContext); let statusComponent = null; + const isTabFlow = role === "tab"; + if (status) { statusComponent = typeof status === "number" ? ( @@ -67,6 +74,22 @@ export const TabsItem = ({ ); } + 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 = tabIndexProp; + if (isTabFlow && tabIndex === undefined) { + tabIndex = selected ? 0 : -1; + } + return ( {before &&
{before}
}