Skip to content

Commit

Permalink
feat(SegmentedControl): support using SegmentedControl as tabs (#7960)
Browse files Browse the repository at this point in the history
* feat(SegmentedControl): support using SegmentedControl as tabs

* test(SegmentedControl): add tests

* fix(SegmentedControl): refactor logic

* fix(SegmentedControl): fix before renderning
  • Loading branch information
EldarMuhamethanov authored Dec 11, 2024
1 parent 5d5a08e commit a1285fe
Show file tree
Hide file tree
Showing 8 changed files with 498 additions and 169 deletions.
70 changes: 70 additions & 0 deletions packages/vkui/src/components/SegmentedControl/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,73 @@ const [selectedSex, changeSelectedSex] = React.useState();
</Panel>
</View>;
```

## Использование в качестве навигации по табам

Компонент `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 (
<View activePanel="panel">
<Panel id="panel">
<PanelHeader>SegmentedControl</PanelHeader>

<SegmentedControl
role="tablist"
value={selected}
onChange={(value) => 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' && (
<Group id="tab-content-news" aria-labelledby="tab-news" role="tabpanel" tabIndex={0}>
<Div>Контент новостей</Div>
</Group>
)}
{selected === 'recommendations' && (
<Group
id="tab-content-recommendations"
aria-labelledby="tab-recommendations"
role="tabpanel"
tabIndex={0}
>
<Div>Контент рекомендаций</Div>
</Group>
)}
</Panel>
</View>
);
};

<Example />;
```
171 changes: 127 additions & 44 deletions packages/vkui/src/components/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SegmentedControlProps, 'name' | 'options'>) => (
<SegmentedControl data-testid="ctrl" {...props} name="test" options={options} />
);
const ctrl = () => screen.getByTestId('ctrl');
const option = (idx = 0) => ctrl().querySelectorAll("input[type='radio']")[idx];

describe('SegmentedControl', () => {
baselineComponent((props) => <SegmentedControl options={[]} name="" {...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(<SegmentedControlTest />);
expect(option(0)).toBeChecked();
});
const SegmentedControlTest = (props: Omit<SegmentedControlProps, 'name' | 'options'>) => (
<SegmentedControl data-testid="ctrl" {...props} name="test" options={options} />
);
baselineComponent((props) => <SegmentedControl options={[]} name="" {...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(<SegmentedControlTest />);
expect(option(0)).toBeChecked();
});

render(<SegmentedControlTest value={initialValue} />);
expect(option(optionIdx)).toBeChecked();
});
it('sets initial value if value is passed', () => {
const initialValue = 'fb';
const optionIdx = options.findIndex((option) => option.value === initialValue);

render(<SegmentedControlTest value={initialValue} />);
expect(option(optionIdx)).toBeChecked();
});

it('uses passed onChange', () => {
const onChange = jest.fn();

render(<SegmentedControlTest onChange={onChange} defaultValue="fb" />);

it('uses passed onChange', () => {
const onChange = jest.fn();
fireEvent.click(option(0));

render(<SegmentedControlTest onChange={onChange} defaultValue="fb" />);
expect(onChange).toHaveBeenCalled();
expect(option(0)).toBeChecked();
});

fireEvent.click(option(0));
it('uses passed onChange with value', () => {
const SegmentedControlTest = () => {
const [value, setValue] = useState<SegmentedControlValue>('fb');

expect(onChange).toHaveBeenCalled();
expect(option(0)).toBeChecked();
return (
<SegmentedControl
data-testid="ctrl"
onChange={setValue}
value={value}
name="test"
options={options}
/>
);
};

render(<SegmentedControlTest />);

expect(option(2)).toBeChecked();
fireEvent.click(option(0));
expect(option(0)).toBeChecked();
});
});

it('uses passed onChange with value', () => {
const SegmentedControlTest = () => {
const [value, setValue] = useState<SegmentedControlValue>('fb');

return (
<SegmentedControl
data-testid="ctrl"
onChange={setValue}
value={value}
name="test"
options={options}
/>
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<SegmentedControlProps, 'options' | 'role'>) => (
<SegmentedControl data-testid="ctrl" {...props} role="tablist" options={options} />
);

const getTab = (idx = 0) => ctrl().querySelectorAll<HTMLLabelElement>('[role="tab"]')[idx];

it('renders elements as tabs', () => {
render(<SegmentedControlTabsTest />);
expect(screen.queryByRole('tablist')).toBeTruthy();
expect(getTab(0)).toHaveAttribute('role', 'tab');
});

it('sets aria-selected correctly', () => {
render(<SegmentedControlTabsTest defaultValue="fb" />);
expect(getTab(2)).toHaveAttribute('aria-selected', 'true');
expect(getTab(0)).toHaveAttribute('aria-selected', 'false');
});

it('switches on click', () => {
const onChange = jest.fn();
render(<SegmentedControlTabsTest onChange={onChange} defaultValue="fb" />);

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(<SegmentedControlTabsTest defaultValue="vk" />);

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(<SegmentedControlTabsTest defaultValue="vk" />);

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(<SegmentedControlTabsTest />);

const ids = new Set(
Array.from(ctrl().querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute('id')),
);
};

render(<SegmentedControlTest />);
expect(ids.size).toBe(options.length);
});

it('matches tab id with its panel via aria-controls', () => {
render(<SegmentedControlTabsTest />);

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);
});
});
});
});
58 changes: 45 additions & 13 deletions packages/vkui/src/components/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ 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 SegmentedControlOptionProps,
} from './SegmentedControlOption/SegmentedControlOption';
import styles from './SegmentedControl.module.css';

const sizeYClassNames = {
Expand Down Expand Up @@ -52,6 +56,7 @@ export const SegmentedControl = ({
children,
onChange: onChangeProp,
value: valueProp,
role = 'radiogroup',
...restProps
}: SegmentedControlProps): React.ReactNode => {
const id = React.useId();
Expand All @@ -64,6 +69,8 @@ export const SegmentedControl = ({

const { sizeY = 'none' } = useAdaptivity();

const { tabsRef } = useTabsNavigation(role === 'tablist');

const actualIndex = options.findIndex((option) => option.value === value);

useIsomorphicLayoutEffect(() => {
Expand All @@ -83,7 +90,7 @@ export const SegmentedControl = ({
size === 'l' && styles.sizeL,
)}
>
<div role="radiogroup" className={styles.in}>
<div role={role} ref={tabsRef} className={styles.in}>
{actualIndex > -1 && (
<div
aria-hidden
Expand All @@ -94,17 +101,42 @@ export const SegmentedControl = ({
}}
/>
)}
{options.map(({ label, ...optionProps }) => (
<SegmentedControlOption
key={`${optionProps.value}`}
{...optionProps}
name={name ?? id}
checked={value === optionProps.value}
onChange={() => onChange(optionProps.value)}
>
{label}
</SegmentedControlOption>
))}
{options.map(({ label, before, ...optionProps }) => {
const selected = value === optionProps.value;
const onSelect = () => onChange(optionProps.value);
const optionRootProps: SegmentedControlOptionProps['rootProps'] =
role === 'tablist'
? {
'role': 'tab',
'aria-selected': selected,
'onClick': onSelect,
'tabIndex': optionProps.tabIndex ?? (selected ? 0 : -1),
...optionProps,
}
: undefined;

const optionInputProps: SegmentedControlOptionProps['inputProps'] =
role !== 'tablist'
? {
role: optionProps.role || (role === 'radiogroup' ? 'radio' : undefined),
checked: selected,
onChange: onSelect,
name: name ?? id,
...optionProps,
}
: undefined;

return (
<SegmentedControlOption
key={`${optionProps.value}`}
before={before}
rootProps={optionRootProps}
inputProps={optionInputProps}
>
{label}
</SegmentedControlOption>
);
})}
</div>
</RootComponent>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { baselineComponent } from '../../../testing/utils';
import { SegmentedControlOption } from './SegmentedControlOption';
import { SegmentedControlOption, type SegmentedControlOptionProps } from './SegmentedControlOption';

describe('SegmentedControlOption', () => {
baselineComponent((props) => (
<SegmentedControlOption {...props}>SegmentedControlOption</SegmentedControlOption>
baselineComponent<SegmentedControlOptionProps>(({ getRef, getRootRef, ...props }) => (
<SegmentedControlOption
inputProps={{ ...props, role: 'radio' }}
getRef={getRef}
getRootRef={getRootRef}
>
SegmentedControlOption
</SegmentedControlOption>
));
});
Loading

0 comments on commit a1285fe

Please sign in to comment.