Skip to content

Commit

Permalink
fix(Switch): Show focus in android talkback (#6365) (#6405)
Browse files Browse the repository at this point in the history
Действительно, на Android в Talkback не видно фокуса при фокусировании на инпуте, потому что мы прячем этот инпут с помощью `VisuallyHidden` компонента.
Как вариант можно убрать использование VisuallyHidden и спрятать компонент позади визульной части.

Изменения:
- Добавил модификатор для VisuallyHidden, когда речь идет о `Component=input`. Явно задал размер равный размеру родительского элемента, чтобы выделение визуально было подобно размеру switch. Но изменение затронуло не только Switch, но и другие компоненты, в которых `<VisuallyHidden Component="input" \>`.
- добавил `role='switch'`
- добавил `aria-checked`. Так как значение этого аттрибута должно соответствовать значению инпута, то добавил переменную состояния.
- обновил пример в Storybook, добавив историю с испольованием SimpleCell чтобы как-то учесть пожелания из #4931
  • Loading branch information
mendrew authored and actions-user committed Jan 19, 2024
1 parent 52dab05 commit 84daced
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 2 deletions.
12 changes: 12 additions & 0 deletions packages/vkui/src/components/Switch/Switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { SimpleCell } from '../../components/SimpleCell/SimpleCell';
import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants';
import { Switch, SwitchProps } from './Switch';

Expand All @@ -18,3 +20,13 @@ export default story;
type Story = StoryObj<SwitchProps>;

export const Playground: Story = {};

export const WithSimpleCellLabel: Story = {
render: function Render(args) {
return (
<SimpleCell Component="label" after={<Switch {...args} />}>
Комментарии к записям
</SimpleCell>
);
},
};
82 changes: 82 additions & 0 deletions packages/vkui/src/components/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { baselineComponent } from '../../testing/utils';
import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden';
import { Switch } from './Switch';
Expand All @@ -12,4 +14,84 @@ describe('Switch', () => {
<Switch aria-labelledby="switch" {...props} />
</>
));

it('(Uncontrolled) shows checked state', async () => {
const { rerender } = render(<Switch data-testid="switch" />);

const component = screen.getByRole<HTMLInputElement>('switch');
if (!component) {
throw new Error('Can not find component');
}

expect(component.checked).toBeFalsy();
expect(component.getAttribute('aria-checked')).toBe('false');

fireEvent.click(component);

expect(component.checked).toBeTruthy();
expect(component.getAttribute('aria-checked')).toBe('true');

rerender(<Switch data-testid="switch" defaultChecked />);

const defaultCheckedComponent = screen.getByTestId<HTMLInputElement>('switch');
if (!defaultCheckedComponent) {
throw new Error('Can not find component');
}

expect(defaultCheckedComponent.checked).toBeTruthy();
expect(defaultCheckedComponent.getAttribute('aria-checked')).toBe('true');

fireEvent.click(defaultCheckedComponent);

expect(defaultCheckedComponent.checked).toBeFalsy();
expect(defaultCheckedComponent.getAttribute('aria-checked')).toBe('false');

rerender(<Switch data-testid="switch" disabled />);

const disabledSwitch = screen.getByTestId<HTMLInputElement>('switch');
if (!disabledSwitch) {
throw new Error('Can not find component');
}
expect(disabledSwitch.checked).toBeFalsy();
expect(disabledSwitch.getAttribute('aria-checked')).toBe('false');

jest.useFakeTimers();
userEvent.click(disabledSwitch);

expect(disabledSwitch.checked).toBeFalsy();
expect(disabledSwitch.getAttribute('aria-checked')).toBe('false');
});

it('(Controlled) shows checked state', () => {
function ControlledSwitch() {
const [checked, setChecked] = React.useState(false);
return (
<React.Fragment>
<Switch data-testid="switch" checked={checked} onChange={jest.fn} />
<button onClick={() => setChecked((prevChecked) => !prevChecked)}>
change switch state
</button>
</React.Fragment>
);
}
render(<ControlledSwitch />);

const switchComponent = screen.getByRole<HTMLInputElement>('switch');
if (!switchComponent) {
throw new Error('Can not find component');
}

expect(switchComponent.checked).toBeFalsy();
expect(switchComponent.getAttribute('aria-checked')).toBe('false');

fireEvent.click(switchComponent);

expect(switchComponent.checked).toBeFalsy();
expect(switchComponent.getAttribute('aria-checked')).toBe('false');

fireEvent.click(screen.getByRole('button'));

expect(switchComponent.checked).toBeTruthy();
expect(switchComponent.getAttribute('aria-checked')).toBe('true');
});
});
31 changes: 30 additions & 1 deletion packages/vkui/src/components/Switch/Switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,37 @@ export interface SwitchProps
/**
* @see https://vkcom.github.io/VKUI/#/Switch
*/
export const Switch = ({ style, className, getRootRef, getRef, ...restProps }: SwitchProps) => {
export const Switch = ({
style,
className,
getRootRef,
getRef,
checked: checkedProp,
...restProps
}: SwitchProps) => {
const platform = usePlatform();
const { sizeY = 'none' } = useAdaptivity();
const { focusVisible, onBlur, onFocus } = useFocusVisible();
const focusVisibleClassNames = useFocusVisibleClassName({ focusVisible, mode: 'outside' });

const [localUncontrolledChecked, setLocalUncontrolledChecked] = React.useState(
Boolean(restProps.defaultChecked),
);
const isControlled = checkedProp !== undefined;

const syncUncontrolledCheckedStateOnClick = React.useCallback(
(e: React.MouseEvent<HTMLInputElement>) => {
if (isControlled) {
return;
}

const switchTarget = e.target as HTMLInputElement;
setLocalUncontrolledChecked(switchTarget.checked);
},
[isControlled],
);

const ariaCheckedState = isControlled ? checkedProp : localUncontrolledChecked;
return (
<label
className={classNames(
Expand All @@ -47,9 +72,13 @@ export const Switch = ({ style, className, getRootRef, getRef, ...restProps }: S
>
<VisuallyHidden
{...restProps}
{...(isControlled && { checked: checkedProp })}
Component="input"
getRootRef={getRef}
onClick={callMultiple(syncUncontrolledCheckedStateOnClick, restProps.onClick)}
type="checkbox"
role="switch"
aria-checked={ariaCheckedState ? 'true' : 'false'}
className={styles['Switch__self']}
/>
<span aria-hidden className={styles['Switch__pseudo']} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,16 @@
border: 0 !important;
opacity: 0;
}

/* Чтобы фокус скринридера, попавший на скрытый инпут был виден.
* Особенно актуально для Android TalkBack */
.VisuallyHidden--focusable-input {
inset-inline-start: 0;
inset-block-start: 0;
block-size: 100% !important;
inline-size: 100% !important;
clip: auto !important;
clip-path: none !important;
pointer-events: none;
}
/* stylelint-enable declaration-no-important */
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { baselineComponent } from '../../testing/utils';
import { VisuallyHidden } from './VisuallyHidden';
import styles from './VisuallyHidden.module.css';

describe('VisuallyHidden', () => {
baselineComponent(VisuallyHidden);

it('uses modifier to keep screen reader focus for input components', () => {
const { rerender } = render(<VisuallyHidden data-testid="visually-hidden" />);

const element = screen.getByTestId('visually-hidden');
expect(element).toHaveClass(styles['VisuallyHidden']);
expect(element).not.toHaveClass(styles['VisuallyHidden--focusable-input']);

rerender(<VisuallyHidden data-testid="visually-hidden" Component="input" />);
const inputElement = screen.getByTestId('visually-hidden');
expect(inputElement).toHaveClass(styles['VisuallyHidden']);
expect(inputElement).toHaveClass(styles['VisuallyHidden--focusable-input']);
});
});
10 changes: 9 additions & 1 deletion packages/vkui/src/components/VisuallyHidden/VisuallyHidden.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { HasComponent, HasRootRef } from '../../types';
import { RootComponent } from '../RootComponent/RootComponent';
import styles from './VisuallyHidden.module.css';
Expand All @@ -16,5 +17,12 @@ interface VisuallyHiddenProps
* @see https://vkcom.github.io/VKUI/#/VisuallyHidden
*/
export const VisuallyHidden = ({ Component = 'span', ...restProps }: VisuallyHiddenProps) => (
<RootComponent Component={Component} {...restProps} baseClassName={styles['VisuallyHidden']} />
<RootComponent
Component={Component}
{...restProps}
baseClassName={classNames(
styles['VisuallyHidden'],
Component === 'input' && styles['VisuallyHidden--focusable-input'],
)}
/>
);

0 comments on commit 84daced

Please sign in to comment.