From acdb8a2a1ec4c4c769107094169ed11573fcb0ce Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 19 Nov 2024 16:22:44 +0300 Subject: [PATCH 1/4] feat(SegmentedControl): support using SegmentedControl as tabs --- .../src/components/SegmentedControl/Readme.md | 70 ++++++++++ .../SegmentedControl/SegmentedControl.tsx | 53 ++++++-- .../SegmentedControlOption.test.tsx | 4 +- .../SegmentedControlOption.tsx | 118 +++++++++++----- packages/vkui/src/components/Tabs/Tabs.tsx | 101 +------------- .../vkui/src/hooks/useTabsNavigation.test.tsx | 127 ++++++++++++++++++ packages/vkui/src/hooks/useTabsNavigation.ts | 104 ++++++++++++++ 7 files changed, 432 insertions(+), 145 deletions(-) create mode 100644 packages/vkui/src/hooks/useTabsNavigation.test.tsx create mode 100644 packages/vkui/src/hooks/useTabsNavigation.ts diff --git a/packages/vkui/src/components/SegmentedControl/Readme.md b/packages/vkui/src/components/SegmentedControl/Readme.md index a871e18f57..2f9d205772 100644 --- a/packages/vkui/src/components/SegmentedControl/Readme.md +++ b/packages/vkui/src/components/SegmentedControl/Readme.md @@ -85,3 +85,73 @@ const [selectedSex, changeSelectedSex] = React.useState(); ; ``` + +## Использование в качестве навигации по табам + +Компонент `SegmentedControl` может использоваться для создания навигации по табам. В этом случае необходимо: + +1. Установить `role="tablist"` для контейнера с табами +2. Для каждой опции указать: + - `id`- уникальный идентификатор таба + - `aria-controls`- идентификатор панели с контентом, которым управляет таб +3. Для панелей с контентом указать: + - `role="tabpanel"`- роль панели с контентом + - `aria-labelledby`- идентификатор таба, который управляет этой панелью + - `tabIndex={0}`- чтобы сделать панель фокусируемой + - `id`- идентификатор панели, который соответствует `aria-controls` в табе + +Это обеспечит правильную семантику и доступность компонента для пользователей скринридеров. + +Пример использования: + +```jsx +const Example = () => { + const [selected, setSelected] = React.useState('news'); + + return ( + + + SegmentedControl + + setSelected(value)} + options={[ + { + 'label': 'Новости', + 'value': 'news', + 'aria-controls': 'tab-content-news', + 'id': 'tab-news', + }, + { + 'label': 'Интересное', + 'value': 'recommendations', + 'aria-controls': 'tab-content-recommendations', + 'id': 'tab-recommendations', + }, + ]} + /> + + {selected === 'news' && ( + +
Контент новостей
+
+ )} + {selected === 'recommendations' && ( + +
Контент рекомендаций
+
+ )} +
+
+ ); +}; + +; +``` diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx index e642ddff4d..1a59472acf 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx @@ -4,11 +4,16 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useCustomEnsuredControl } from '../../hooks/useEnsuredControl'; +import { useTabsNavigation } from '../../hooks/useTabsNavigation'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import { warnOnce } from '../../lib/warnOnce'; import type { HTMLAttributesWithRootRef } from '../../types'; import { RootComponent } from '../RootComponent/RootComponent'; -import { SegmentedControlOption } from './SegmentedControlOption/SegmentedControlOption'; +import { + SegmentedControlOption, + type SegmentedControlRadioProps, + type SegmentedControlTabProps, +} from './SegmentedControlOption/SegmentedControlOption'; import styles from './SegmentedControl.module.css'; const sizeYClassNames = { @@ -30,13 +35,14 @@ export interface SegmentedControlOptionInterface } export interface SegmentedControlProps - extends Omit, 'onChange'> { + extends Omit, 'onChange' | 'role'> { options: SegmentedControlOptionInterface[]; size?: 'm' | 'l'; name?: string; onChange?: (value: SegmentedControlValue) => void; value?: SegmentedControlValue; defaultValue?: SegmentedControlValue; + role?: 'tablist' | 'radiogroup'; } const warn = warnOnce('SegmentedControl'); @@ -52,6 +58,7 @@ export const SegmentedControl = ({ children, onChange: onChangeProp, value: valueProp, + role = 'radiogroup', ...restProps }: SegmentedControlProps): React.ReactNode => { const id = React.useId(); @@ -64,6 +71,8 @@ export const SegmentedControl = ({ const { sizeY = 'none' } = useAdaptivity(); + const { tabsRef } = useTabsNavigation(role === 'tablist'); + const actualIndex = options.findIndex((option) => option.value === value); useIsomorphicLayoutEffect(() => { @@ -83,7 +92,7 @@ export const SegmentedControl = ({ size === 'l' && styles.sizeL, )} > -
+
{actualIndex > -1 && (
)} - {options.map(({ label, ...optionProps }) => ( - onChange(optionProps.value)} - > - {label} - - ))} + {options.map(({ label, ...optionProps }) => { + const selected = value === optionProps.value; + const onSelect = () => onChange(optionProps.value); + const roleOptionProps: SegmentedControlTabProps | SegmentedControlRadioProps = + role === 'tablist' + ? { + 'role': 'tab', + 'aria-selected': selected, + 'onClick': onSelect, + } + : { + role: 'radio', + checked: selected, + onChange: onSelect, + name: name ?? id, + }; + + return ( + + {label} + + ); + })}
); diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx index 572214ed11..00c053eea7 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx @@ -3,6 +3,8 @@ import { SegmentedControlOption } from './SegmentedControlOption'; describe('SegmentedControlOption', () => { baselineComponent((props) => ( - SegmentedControlOption + + SegmentedControlOption + )); }); diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx index 7750658009..d2bdb1a5b0 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx @@ -1,43 +1,99 @@ +'use client'; + import * as React from 'react'; import { hasReactNode } from '@vkontakte/vkjs'; -import type { HasRef, HasRootRef } from '../../../types'; +import type { HasChildren, HasRef, HasRootRef } from '../../../types'; import { Clickable } from '../../Clickable/Clickable'; import { Headline } from '../../Typography/Headline/Headline'; import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; import styles from './SegmentedControlOption.module.css'; -export interface SegmentedControlOptionProps - extends React.InputHTMLAttributes, - HasRootRef, - HasRef { +type LabelProps = Pick, 'style' | 'className'>; + +type HasBefore = { before?: React.ReactNode; -} +}; + +export type SegmentedControlTabProps = Omit< + React.LabelHTMLAttributes, + keyof LabelProps | 'children' +> & { + role: 'tab'; +}; + +export type SegmentedControlRadioProps = Omit< + React.InputHTMLAttributes, + 'children' +> & { + role: 'radio'; +}; + +type SegmentedControlOptionProps = LabelProps & + HasRootRef & + HasRef & + HasChildren & + HasBefore & + (SegmentedControlTabProps | SegmentedControlRadioProps); + +const remapProps = ( + props: SegmentedControlOptionProps, +): [ + SegmentedControlTabProps | SegmentedControlRadioProps, + Omit, +] => { + const { getRef, className, style, children, getRootRef, before, ...restProps } = props; + const componentProps = { + getRef, + className, + style, + children, + getRootRef, + before, + }; + return [restProps, componentProps]; +}; /** * @see https://vkcom.github.io/VKUI/#/SegmentedControl */ -export const SegmentedControlOption = ({ - getRef, - className, - style, - children, - getRootRef, - before, - ...restProps -}: SegmentedControlOptionProps): React.ReactNode => ( - - - {hasReactNode(before) &&
{before}
} - - {children} - -
-); +export const SegmentedControlOption = (props: SegmentedControlOptionProps): React.ReactNode => { + const [roleProps, componentProps] = remapProps(props); + + const { getRef, className, style, children, getRootRef, before } = componentProps; + + const clickableProps = (() => { + if (roleProps.role !== 'tab') { + return undefined; + } + const { role, 'aria-selected': ariaSelected, tabIndex, ...restProps } = roleProps; + return { + 'role': 'tab', + 'aria-selected': ariaSelected, + 'tabIndex': tabIndex ?? (ariaSelected ? 0 : -1), + ...restProps, + }; + })(); + + const inputProps = roleProps.role === 'radio' ? roleProps : undefined; + + return ( + + {inputProps && ( + + )} + {hasReactNode(before) &&
{before}
} + + {children} + +
+ ); +}; diff --git a/packages/vkui/src/components/Tabs/Tabs.tsx b/packages/vkui/src/components/Tabs/Tabs.tsx index 633d729caa..f639e0aba2 100644 --- a/packages/vkui/src/components/Tabs/Tabs.tsx +++ b/packages/vkui/src/components/Tabs/Tabs.tsx @@ -2,10 +2,8 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; -import { useGlobalEventListener } from '../../hooks/useGlobalEventListener'; import { usePlatform } from '../../hooks/usePlatform'; -import { pressedKey } from '../../lib/accessibility'; -import { useDOM } from '../../lib/dom'; +import { useTabsNavigation } from '../../hooks/useTabsNavigation'; import type { HTMLAttributesWithRootRef } from '../../types'; import { RootComponent } from '../RootComponent/RootComponent'; import styles from './Tabs.module.css'; @@ -63,105 +61,10 @@ export const Tabs = ({ ...restProps }: TabsProps): React.ReactNode => { const platform = usePlatform(); - const { document } = useDOM(); - const isTabFlow = role === 'tablist'; - - const tabsRef = React.useRef(null); - 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, - }); + const { tabsRef } = useTabsNavigation(isTabFlow); return ( { + const setupTabs = () => { + const container = document.createElement('div'); + const tabs = document.createElement('div'); + const tab1 = document.createElement('div'); + const tab2 = document.createElement('div'); + const tab3 = document.createElement('div'); + const content1 = document.createElement('div'); + + tabs.setAttribute('role', 'tablist'); + [tab1, tab2, tab3].forEach((tab, index) => { + tab.setAttribute('role', 'tab'); + tab.setAttribute('tabindex', '0'); + tab.setAttribute('aria-controls', `content-${index}`); + }); + + tab1.setAttribute('aria-selected', 'true'); + content1.setAttribute('id', 'content-0'); + content1.setAttribute('tabindex', '-1'); + + tabs.append(tab1, tab2, tab3); + container.append(tabs, content1); + document.body.append(container); + + return { container, tabs, tab1, tab2, tab3, content1 }; + }; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should switch to next tab when right arrow is pressed', () => { + const { tabs, tab1, tab2 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + // Теперь можно безопасно присвоить значение + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab1.focus(); + + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(tab2); + }); + + it('should switch to previous tab when left arrow is pressed', () => { + const { tabs, tab2, tab1 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab2.focus(); + + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + + expect(document.activeElement).toBe(tab1); + }); + + it('should switch to first tab when Home is pressed', () => { + const { tabs, tab3, tab1 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab3.focus(); + + fireEvent.keyDown(document, { key: 'Home' }); + + expect(document.activeElement).toBe(tab1); + }); + + it('should switch to last tab when End is pressed', () => { + const { tabs, tab1, tab3 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab1.focus(); + + fireEvent.keyDown(document, { key: 'End' }); + + expect(document.activeElement).toBe(tab3); + }); + + it('should focus on related content when arrow down is pressed', () => { + const { tabs, tab1, content1 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab1.focus(); + + fireEvent.keyDown(document, { key: 'ArrowDown' }); + + expect(document.activeElement).toBe(content1); + }); + + it('should activate tab when Enter is pressed', () => { + const { tabs, tab1 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation()); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab1.focus(); + + const clickSpy = jest.spyOn(tab1, 'click'); + + fireEvent.keyDown(document, { key: 'Enter' }); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('should not react to keys when enabled is false', () => { + const { tabs, tab1, tab2 } = setupTabs(); + const { result } = renderHook(() => useTabsNavigation(false)); + + (result.current.tabsRef as React.MutableRefObject).current = tabs; + tab1.focus(); + + fireEvent.keyDown(document, { key: 'ArrowRight' }); + + expect(document.activeElement).toBe(tab1); + expect(document.activeElement).not.toBe(tab2); + }); +}); diff --git a/packages/vkui/src/hooks/useTabsNavigation.ts b/packages/vkui/src/hooks/useTabsNavigation.ts new file mode 100644 index 0000000000..022b3f6761 --- /dev/null +++ b/packages/vkui/src/hooks/useTabsNavigation.ts @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { pressedKey } from '../lib/accessibility'; +import { useDOM } from '../lib/dom'; +import { useGlobalEventListener } from './useGlobalEventListener'; + +export function useTabsNavigation(enabled = true) { + const { document } = useDOM(); + const tabsRef = React.useRef(null); + 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 || !enabled) { + 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 { + tabsRef, + }; +} From 5a19b9a95b656b5051224a697b920a4dbaeb0d23 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 19 Nov 2024 16:46:48 +0300 Subject: [PATCH 2/4] test(SegmentedControl): add tests --- .../SegmentedControl.test.tsx | 171 +++++++++++++----- .../SegmentedControl/SegmentedControl.tsx | 2 +- 2 files changed, 128 insertions(+), 45 deletions(-) diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControl.test.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControl.test.tsx index 1f465ad708..1295b9d644 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControl.test.tsx @@ -7,65 +7,148 @@ import { type SegmentedControlProps, type SegmentedControlValue, } from './SegmentedControl'; - -const options: SegmentedControlOptionInterface[] = [ - { label: 'vk', value: 'vk' }, - { label: 'ok', value: 'ok' }, - { label: 'fb', value: 'fb' }, -]; - -const SegmentedControlTest = (props: Omit) => ( - -); const ctrl = () => screen.getByTestId('ctrl'); const option = (idx = 0) => ctrl().querySelectorAll("input[type='radio']")[idx]; describe('SegmentedControl', () => { - baselineComponent((props) => ); + describe('radio mode', () => { + const options: SegmentedControlOptionInterface[] = [ + { label: 'vk', value: 'vk' }, + { label: 'ok', value: 'ok' }, + { label: 'fb', value: 'fb' }, + ]; - it('uses the first option value as initial', () => { - render(); - expect(option(0)).toBeChecked(); - }); + const SegmentedControlTest = (props: Omit) => ( + + ); + baselineComponent((props) => ); - it('sets initial value if value is passed', () => { - const initialValue = 'fb'; - const optionIdx = options.findIndex((option) => option.value === initialValue); + it('uses the first option value as initial', () => { + render(); + expect(option(0)).toBeChecked(); + }); - render(); - expect(option(optionIdx)).toBeChecked(); - }); + it('sets initial value if value is passed', () => { + const initialValue = 'fb'; + const optionIdx = options.findIndex((option) => option.value === initialValue); + + render(); + expect(option(optionIdx)).toBeChecked(); + }); + + it('uses passed onChange', () => { + const onChange = jest.fn(); + + render(); - it('uses passed onChange', () => { - const onChange = jest.fn(); + fireEvent.click(option(0)); - render(); + expect(onChange).toHaveBeenCalled(); + expect(option(0)).toBeChecked(); + }); - fireEvent.click(option(0)); + it('uses passed onChange with value', () => { + const SegmentedControlTest = () => { + const [value, setValue] = useState('fb'); - expect(onChange).toHaveBeenCalled(); - expect(option(0)).toBeChecked(); + return ( + + ); + }; + + render(); + + expect(option(2)).toBeChecked(); + fireEvent.click(option(0)); + expect(option(0)).toBeChecked(); + }); }); - it('uses passed onChange with value', () => { - const SegmentedControlTest = () => { - const [value, setValue] = useState('fb'); - - return ( - + describe('tabs mode', () => { + const options: SegmentedControlOptionInterface[] = [ + { 'label': 'vk', 'value': 'vk', 'id': 'vk', 'aria-controls': 'vk-content' }, + { 'label': 'ok', 'value': 'ok', 'id': 'ok', 'aria-controls': 'ok-content' }, + { 'label': 'fb', 'value': 'fb', 'id': 'fb', 'aria-controls': 'fb-content' }, + ]; + + const SegmentedControlTabsTest = (props: Omit) => ( + + ); + + const getTab = (idx = 0) => ctrl().querySelectorAll('[role="tab"]')[idx]; + + it('renders elements as tabs', () => { + render(); + expect(screen.queryByRole('tablist')).toBeTruthy(); + expect(getTab(0)).toHaveAttribute('role', 'tab'); + }); + + it('sets aria-selected correctly', () => { + render(); + expect(getTab(2)).toHaveAttribute('aria-selected', 'true'); + expect(getTab(0)).toHaveAttribute('aria-selected', 'false'); + }); + + it('switches on click', () => { + const onChange = jest.fn(); + render(); + + fireEvent.click(getTab(0)); + + expect(onChange).toHaveBeenCalledWith('vk'); + expect(getTab(0)).toHaveAttribute('aria-selected', 'true'); + expect(getTab(2)).toHaveAttribute('aria-selected', 'false'); + }); + + it('supports keyboard navigation', () => { + render(); + + getTab(0).focus(); + fireEvent.keyDown(getTab(0), { key: 'ArrowRight' }); + expect(document.activeElement).toBe(getTab(1)); + + fireEvent.keyDown(getTab(1), { key: 'ArrowLeft' }); + expect(document.activeElement).toBe(getTab(0)); + }); + + it('sets correct aria attributes', () => { + render(); + + options.forEach((_, idx) => { + const tab = getTab(idx); + expect(tab).toHaveAttribute('id'); + expect(tab).toHaveAttribute('aria-controls', expect.any(String)); + }); + }); + + it('generates unique ids for each tab', () => { + render(); + + const ids = new Set( + Array.from(ctrl().querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute('id')), ); - }; - render(); + expect(ids.size).toBe(options.length); + }); + + it('matches tab id with its panel via aria-controls', () => { + render(); + + options.forEach((_, idx) => { + const tab = getTab(idx); + const tabId = tab.getAttribute('id'); + const panelId = tab.getAttribute('aria-controls'); - expect(option(2)).toBeChecked(); - fireEvent.click(option(0)); - expect(option(0)).toBeChecked(); + expect(tabId).toBeTruthy(); + expect(panelId).toBeTruthy(); + expect(tabId).not.toBe(panelId); + }); + }); }); }); diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx index 1a59472acf..08a445ca92 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx @@ -92,7 +92,7 @@ export const SegmentedControl = ({ size === 'l' && styles.sizeL, )} > -
+
{actualIndex > -1 && (
Date: Wed, 11 Dec 2024 10:39:31 +0300 Subject: [PATCH 3/4] fix(SegmentedControl): refactor logic --- .../SegmentedControl/SegmentedControl.tsx | 28 +++-- .../SegmentedControlOption.test.tsx | 10 +- .../SegmentedControlOption.tsx | 116 +++++------------- 3 files changed, 56 insertions(+), 98 deletions(-) diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx index 08a445ca92..b85cfd8933 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx @@ -11,8 +11,7 @@ import type { HTMLAttributesWithRootRef } from '../../types'; import { RootComponent } from '../RootComponent/RootComponent'; import { SegmentedControlOption, - type SegmentedControlRadioProps, - type SegmentedControlTabProps, + type SegmentedControlOptionProps, } from './SegmentedControlOption/SegmentedControlOption'; import styles from './SegmentedControl.module.css'; @@ -35,14 +34,13 @@ export interface SegmentedControlOptionInterface } export interface SegmentedControlProps - extends Omit, 'onChange' | 'role'> { + extends Omit, 'onChange'> { options: SegmentedControlOptionInterface[]; size?: 'm' | 'l'; name?: string; onChange?: (value: SegmentedControlValue) => void; value?: SegmentedControlValue; defaultValue?: SegmentedControlValue; - role?: 'tablist' | 'radiogroup'; } const warn = warnOnce('SegmentedControl'); @@ -92,7 +90,7 @@ export const SegmentedControl = ({ size === 'l' && styles.sizeL, )} > -
+
{actualIndex > -1 && (
{ const selected = value === optionProps.value; const onSelect = () => onChange(optionProps.value); - const roleOptionProps: SegmentedControlTabProps | SegmentedControlRadioProps = + const optionRootProps: SegmentedControlOptionProps['rootProps'] = role === 'tablist' ? { 'role': 'tab', 'aria-selected': selected, 'onClick': onSelect, + 'tabIndex': optionProps.tabIndex ?? (selected ? 0 : -1), + ...optionProps, } - : { - role: 'radio', + : undefined; + + const optionInputProps: SegmentedControlOptionProps['inputProps'] = + role !== 'tablist' + ? { + role: optionProps.role || (role === 'radiogroup' ? 'radio' : undefined), checked: selected, onChange: onSelect, name: name ?? id, - }; + ...optionProps, + } + : undefined; return ( {label} diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx index 00c053eea7..3582b74c5a 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.test.tsx @@ -1,9 +1,13 @@ import { baselineComponent } from '../../../testing/utils'; -import { SegmentedControlOption } from './SegmentedControlOption'; +import { SegmentedControlOption, type SegmentedControlOptionProps } from './SegmentedControlOption'; describe('SegmentedControlOption', () => { - baselineComponent((props) => ( - + baselineComponent(({ getRef, getRootRef, ...props }) => ( + SegmentedControlOption )); diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx index d2bdb1a5b0..9780b57922 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx @@ -8,92 +8,40 @@ import { Headline } from '../../Typography/Headline/Headline'; import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; import styles from './SegmentedControlOption.module.css'; -type LabelProps = Pick, 'style' | 'className'>; - -type HasBefore = { +export interface SegmentedControlOptionProps + extends HasRootRef, + HasRef, + HasChildren { + rootProps?: React.LabelHTMLAttributes; + inputProps?: React.InputHTMLAttributes; before?: React.ReactNode; -}; - -export type SegmentedControlTabProps = Omit< - React.LabelHTMLAttributes, - keyof LabelProps | 'children' -> & { - role: 'tab'; -}; - -export type SegmentedControlRadioProps = Omit< - React.InputHTMLAttributes, - 'children' -> & { - role: 'radio'; -}; - -type SegmentedControlOptionProps = LabelProps & - HasRootRef & - HasRef & - HasChildren & - HasBefore & - (SegmentedControlTabProps | SegmentedControlRadioProps); - -const remapProps = ( - props: SegmentedControlOptionProps, -): [ - SegmentedControlTabProps | SegmentedControlRadioProps, - Omit, -] => { - const { getRef, className, style, children, getRootRef, before, ...restProps } = props; - const componentProps = { - getRef, - className, - style, - children, - getRootRef, - before, - }; - return [restProps, componentProps]; -}; +} /** * @see https://vkcom.github.io/VKUI/#/SegmentedControl */ -export const SegmentedControlOption = (props: SegmentedControlOptionProps): React.ReactNode => { - const [roleProps, componentProps] = remapProps(props); - - const { getRef, className, style, children, getRootRef, before } = componentProps; - - const clickableProps = (() => { - if (roleProps.role !== 'tab') { - return undefined; - } - const { role, 'aria-selected': ariaSelected, tabIndex, ...restProps } = roleProps; - return { - 'role': 'tab', - 'aria-selected': ariaSelected, - 'tabIndex': tabIndex ?? (ariaSelected ? 0 : -1), - ...restProps, - }; - })(); - - const inputProps = roleProps.role === 'radio' ? roleProps : undefined; - - return ( - - {inputProps && ( - - )} - {hasReactNode(before) &&
{before}
} - - {children} - -
- ); -}; +export const SegmentedControlOption = ({ + getRef, + children, + getRootRef, + before, + rootProps, + inputProps, +}: SegmentedControlOptionProps): React.ReactNode => ( + + {inputProps && ( + + )} + {hasReactNode(before) &&
{before}
} + + {children} + +
+); From a6e884e19e3fca9e72e26daa4fd8d82caf43cdc7 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Wed, 11 Dec 2024 13:58:58 +0300 Subject: [PATCH 4/4] fix(SegmentedControl): fix before renderning --- .../vkui/src/components/SegmentedControl/SegmentedControl.tsx | 3 ++- .../__image_snapshots__/tabs-android-chromium-dark-1-snap.png | 4 ++-- .../tabs-android-chromium-light-1-snap.png | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx index b85cfd8933..857480b350 100644 --- a/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx +++ b/packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx @@ -101,7 +101,7 @@ export const SegmentedControl = ({ }} /> )} - {options.map(({ label, ...optionProps }) => { + {options.map(({ label, before, ...optionProps }) => { const selected = value === optionProps.value; const onSelect = () => onChange(optionProps.value); const optionRootProps: SegmentedControlOptionProps['rootProps'] = @@ -129,6 +129,7 @@ export const SegmentedControl = ({ return ( diff --git a/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-dark-1-snap.png b/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-dark-1-snap.png index 402bde6021..21f778c1e9 100644 --- a/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-dark-1-snap.png +++ b/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-dark-1-snap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a68e85402679957e978ef768a0f0f2efd7d3e12ea4b7ec8409c4cb6100e6610 -size 182110 +oid sha256:6b180282c4b3588256ce14566c958e8ff17b39e4221f20cf875369f7b62f2df1 +size 182432 diff --git a/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-light-1-snap.png b/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-light-1-snap.png index 84382b0407..3bb881b7f7 100644 --- a/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-light-1-snap.png +++ b/packages/vkui/src/components/Tabs/__image_snapshots__/tabs-android-chromium-light-1-snap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63edcec6eb9b9edab08939705229ef542eec8eca89bdd86d125efb79a0325dff -size 179390 +oid sha256:ca8dcdb2a22abdaa1dcba42d2a2a3513a12350e623106ba087ff924b78570b08 +size 178909