Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.host {
position: relative;
display: flex;
flex-grow: 1;
flex-direction: column;
Expand Down Expand Up @@ -84,6 +85,25 @@
transition: none;
}

/* Скрытая кнопка «Обновить»: невидима зрячим, доступна скринридеру.
При клавиатурном фокусе раскрывается во всплывающую кнопку, чтобы зрячий
пользователь, навигирующийся с клавиатуры, видел, где оказался. */
.refreshAction {
position: absolute;
inset-block-start: 8px;
inset-inline: 0;
z-index: var(--vkui_internal--z_index_pull_to_refresh);
inline-size: fit-content;
margin: auto;
pointer-events: none;
clip-path: inset(50%);
}

.refreshAction:focus {
pointer-events: auto;
clip-path: none;
}

@keyframes animationPullToRefreshToRefreshing {
0% {
transform: scale(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,66 @@ describe(PullToRefresh, () => {
});
});

describe('a11y', () => {
it('renders a hidden refresh button accessible by role', () => {
render(<PullToRefresh onRefresh={noop} data-testid="xxx" />);
const refreshButton = screen.getByRole('button', { name: 'Обновить' });
expect(refreshButton).not.toBeNull();
expect(refreshButton).toHaveAttribute('type', 'button');
});

it('region has aria-label and announces busy state', () => {
render(<PullToRefresh onRefresh={noop} isFetching data-testid="xxx" />);
const host = screen.getByTestId('xxx');
expect(host).toHaveAttribute('role', 'region');
expect(host).toHaveAttribute('aria-label', 'Обновление контента');
expect(host).toHaveAttribute('aria-busy', 'true');
});

it('allows overriding accessibility texts', () => {
render(
<PullToRefresh
onRefresh={noop}
accessibilityLabel="Лента"
refreshLabel="Обновить ленту"
data-testid="xxx"
/>,
);
expect(screen.getByRole('button', { name: 'Обновить ленту' })).not.toBeNull();
expect(screen.getByTestId('xxx')).toHaveAttribute('aria-label', 'Лента');
});

it('calls onRefresh when the hidden button is clicked', () => {
const onRefresh = vi.fn();
render(<PullToRefresh onRefresh={onRefresh} data-testid="xxx" />);
fireEvent.click(screen.getByRole('button', { name: 'Обновить' }));
expect(onRefresh).toHaveBeenCalledTimes(1);
});

it('disables the hidden button while refreshing and re-enables after isFetching=false', () => {
const onRefresh = vi.fn();
const Tree = ({ isFetching }: { isFetching: boolean }) => (
<PullToRefresh onRefresh={onRefresh} isFetching={isFetching} data-testid="xxx" />
);
const { rerender } = render(<Tree isFetching={false} />);

const refreshButton = screen.getByRole('button', { name: 'Обновить' });
fireEvent.click(refreshButton);
expect(onRefresh).toHaveBeenCalledTimes(1);

// пока идёт обновление, кнопка заблокирована
rerender(<Tree isFetching />);
expect(refreshButton).toBeDisabled();

// клик по заблокированной кнопке не запускает onRefresh повторно
fireEvent.click(refreshButton);
expect(onRefresh).toHaveBeenCalledTimes(1);

rerender(<Tree isFetching={false} />);
expect(refreshButton).not.toBeDisabled();
});
});

it('disables native pull-to-refresh while pulling', async () => {
const component = render(
<ConfigProvider platform="ios">
Expand Down
31 changes: 31 additions & 0 deletions packages/vkui/src/components/PullToRefresh/PullToRefresh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import type { AnyFunction, HasChildren } from '../../types';
import { type ScrollContextInterface, useScroll } from '../AppRoot/ScrollContext';
import { Box } from '../Box/Box';
import { Button } from '../Button/Button';
import { ParentWidthWrapper } from '../FixedLayout/ParentWidthWrapper';
import { type CustomTouchEvent, Touch, type TouchProps } from '../Touch/Touch';
import TouchRootContext from '../Touch/TouchContext';
Expand Down Expand Up @@ -41,6 +42,17 @@ export interface PullToRefreshProps extends DOMProps, TouchProps, HasChildren {
* Определяет, выполняется ли обновление. Для скрытия спиннера после получения контента необходимо передать `false`.
*/
isFetching?: boolean | undefined;
/**
* Текст, объявляемый скринридером для области обновления.
*
* > ℹ️ Жест «потянуть вниз» недоступен для пользователей ассистивных технологий, поэтому компонент
* > предоставляет скрытую кнопку «Обновить», доступную с клавиатуры и скринридера.
*/
accessibilityLabel?: string | undefined;
/**
* Текст скрытой кнопки, запускающей обновление для пользователей ассистивных технологий.
*/
refreshLabel?: string | undefined;
/** @ignore */
scroll?: ScrollContextInterface | undefined;
}
Expand All @@ -52,6 +64,8 @@ export const PullToRefresh = ({
children,
isFetching,
onRefresh,
accessibilityLabel = 'Обновление контента',
refreshLabel = 'Обновить',
className,
...restProps
}: PullToRefreshProps): React.ReactNode => {
Expand Down Expand Up @@ -234,6 +248,8 @@ export const PullToRefresh = ({
<Touch
aria-live="polite"
aria-busy={!!isFetching}
role="region"
aria-label={accessibilityLabel}
{...restProps}
onStart={onTouchStart}
onMove={onTouchMove}
Expand All @@ -246,6 +262,21 @@ export const PullToRefresh = ({
className,
)}
>
{/*
Жест «потянуть вниз» недоступен пользователям ассистивных технологий, поэтому
предоставляем скрытую кнопку, запускающую обновление с клавиатуры и скринридера.
При клавиатурном фокусе кнопка раскрывается во всплывающую, чтобы зрячий
пользователь, навигирующийся с клавиатуры, видел, где оказался.
*/}
<Button
className={styles.refreshAction}
mode="secondary"
size="m"
onClick={runRefreshing}
disabled={refreshing || !!isFetching}
>
{refreshLabel}
</Button>
<Box
Component={ParentWidthWrapper}
className={styles.controls}
Expand Down
34 changes: 33 additions & 1 deletion website/content/components/pull-to-refresh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ tags: loading

```jsx
const [users, setUsers] = React.useState([]);
const [fetching, setFetching] = React.useState(true);
const [fetching, setFetching] = React.useState(false);

const onRefresh = React.useCallback(() => {
setFetching(true);
Expand Down Expand Up @@ -59,6 +59,38 @@ return (
- Устанавливайте `isFetching={true}` при начале загрузки.
- Устанавливайте `isFetching={false}` после завершения загрузки.

## Доступность (a11y) [#a11y]

Жест «потянуть вниз» недоступен пользователям ассистивных технологий: в VoiceOver смахивание
тремя пальцами вниз не вызывает `PullToRefresh`.

Вместо жеста компонент отрисовывает **скрытую кнопку «Обновить»**. Она в таб-фокусе, срабатывает
с клавиатуры (Enter/Space) и объявляется скринридером. Кнопка вызывает тот же `onRefresh` и
блокируется на время обновления. Состояние загрузки озвучивается через `aria-busy` и
`aria-live="polite"`.

Чтобы зрячий пользователь, навигирующийся с клавиатуры, видел, где оказался, кнопка (на базе
`Button`) **раскрывается во всплывающую при фокусе** и скрывается при потере фокуса.

Тексты для скринридера переопределяются свойствами:

```jsx
<PullToRefresh
onRefresh={onRefresh}
isFetching={fetching}
accessibilityLabel="Лента пользователей"
refreshLabel="Обновить ленту"
>
{content}
</PullToRefresh>
```

- `accessibilityLabel` — подпись области (по умолчанию «Обновление контента»).
- `refreshLabel` — текст кнопки (по умолчанию «Обновить»).

> Вне фокуса кнопка скрыта. Чтобы показывать её зрячим постоянно, добавьте отдельную кнопку
> (например, в `PanelHeader`) с тем же `onRefresh`.

## Свойства и методы [#api]

<PropsTable name="PullToRefresh" />
Loading