Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SegmentedControl): support using SegmentedControl as tabs #7960

Merged
merged 6 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 />;
```
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);
});
});
});
});
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
Loading