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

patch: pr8247 #8260

Merged
merged 1 commit into from
Feb 12, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,17 @@
inset-block-start: 0;
inline-size: 100%;
block-size: 100%;
padding: 0;
margin: 0;
appearance: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
cursor: default;
background: transparent;
border: 0;
}

.overlay:focus,
.overlay:focus-visible {
outline: none;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Fragment, type HtmlHTMLAttributes, type ReactElement } from 'react';
import { render, screen } from '@testing-library/react';
import { baselineComponent, waitForFloatingPosition } from '../../testing/utils';
import { noop } from '@vkontakte/vkjs';
import { baselineComponent, setNodeEnv, waitForFloatingPosition } from '../../testing/utils';
import type { HasRootRef } from '../../types';
import { OnboardingTooltip, type OnboardingTooltipProps } from './OnboardingTooltip';
import { OnboardingTooltipContainer } from './OnboardingTooltipContainer';
Expand All @@ -20,7 +21,7 @@ describe(OnboardingTooltip, () => {
baselineComponent(
(props) => (
<OnboardingTooltipContainer>
<OnboardingTooltip shown description="text" {...props}>
<OnboardingTooltip shown title="Text element" description="text" {...props}>
<div />
</OnboardingTooltip>
</OnboardingTooltipContainer>
Expand Down Expand Up @@ -124,4 +125,26 @@ describe(OnboardingTooltip, () => {

expect(onPlacementChange).toHaveBeenCalledWith('top');
});

it('shows warning if title and area attributes are not provided', () => {
setNodeEnv('development');
const warn = jest.spyOn(console, 'warn').mockImplementation(noop);

const component = render(<OnboardingTooltip onClose={noop} title="title" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<OnboardingTooltip onClose={noop} aria-label="title" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<OnboardingTooltip onClose={noop} aria-labelledby="labelId" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<OnboardingTooltip onClose={noop} />);

expect(warn.mock.calls[0][0]).toBe(
'%c[VKUI/OnboardingTooltip] Если "title" не используется, то необходимо задать либо "aria-label", либо "aria-labelledby" (см. правило axe aria-dialog-name)',
);

setNodeEnv('test');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';
import { hasReactNode } from '@vkontakte/vkjs';
import { mergeStyle } from '../../helpers/mergeStyle';
import { useExternRef } from '../../hooks/useExternRef';
import { type UseFocusTrapProps } from '../../hooks/useFocusTrap';
import { usePatchChildren } from '../../hooks/usePatchChildren';
import { createPortal } from '../../lib/createPortal';
import {
Expand All @@ -18,6 +19,7 @@ import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import { warnOnce } from '../../lib/warnOnce';
import { DEFAULT_ARROW_HEIGHT, DEFAULT_ARROW_PADDING } from '../FloatingArrow/DefaultIcon';
import type { FloatingArrowProps } from '../FloatingArrow/FloatingArrow';
import { FocusTrap } from '../FocusTrap/FocusTrap';
import { useNavTransition } from '../NavTransitionContext/NavTransitionContext';
import { TOOLTIP_MAX_WIDTH, TooltipBase, type TooltipBaseProps } from '../TooltipBase/TooltipBase';
import { onboardingTooltipContainerAttr } from './OnboardingTooltipContainer';
Expand Down Expand Up @@ -58,7 +60,8 @@ type AllowedFloatingArrowProps = {
export interface OnboardingTooltipProps
extends AllowedFloatingComponentProps,
AllowedTooltipBaseProps,
AllowedFloatingArrowProps {
AllowedFloatingArrowProps,
Pick<UseFocusTrapProps, 'restoreFocus'> {
/**
* Скрывает стрелку, указывающую на якорный элемент.
*/
Expand All @@ -67,29 +70,38 @@ export interface OnboardingTooltipProps
* Callback, который вызывается при клике по любому месту в пределах экрана.
*/
onClose?: (this: void) => void;
/**
* [a11y] Метка для подложки-кнопки, для описания того, что произойдёт при клике.
*/
overlayLabel?: string;
}

/**
* @see https://vkcom.github.io/VKUI/#/Tooltip
*/
export const OnboardingTooltip = ({
id: idProp,
'id': idProp,
children,
shown: shownProp = true,
'shown': shownProp = true,
arrowPadding = DEFAULT_ARROW_PADDING,
arrowHeight = DEFAULT_ARROW_HEIGHT,
offsetByMainAxis = 0,
offsetByCrossAxis = 0,
arrowOffset = 0,
isStaticArrowOffset = false,
onClose,
placement: placementProp = 'bottom-start',
'placement': placementProp = 'bottom-start',
maxWidth = TOOLTIP_MAX_WIDTH,
style: styleProp,
'style': styleProp,
getRootRef,
disableArrow = false,
onPlacementChange,
disableFlipMiddleware = false,
overlayLabel = 'Закрыть',
title,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
restoreFocus,
...restProps
}: OnboardingTooltipProps): React.ReactNode => {
const generatedId = React.useId();
Expand Down Expand Up @@ -130,6 +142,13 @@ export const OnboardingTooltip = ({

usePlacementChangeCallback(placementProp, resolvedPlacement, onPlacementChange);

const titleId = React.useId();
if (process.env.NODE_ENV === 'development' && !title && !ariaLabel && !ariaLabelledBy) {
warn(
'Если "title" не используется, то необходимо задать либо "aria-label", либо "aria-labelledby" (см. правило axe aria-dialog-name)',
);
}

let tooltip: React.ReactPortal | null = null;
if (shown) {
const floatingStyle = convertFloatingDataToReactCSSProperties(
Expand All @@ -139,10 +158,20 @@ export const OnboardingTooltip = ({
);

tooltip = createPortal(
<>
<FocusTrap
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={title ? titleId : ariaLabel ? undefined : ariaLabelledBy}
onClose={onClose}
restoreFocus={restoreFocus}
>
<button aria-label={overlayLabel} className={styles.overlay} onClickCapture={onClose} />
<TooltipBase
{...restProps}
id={tooltipId}
title={title}
titleId={title ? titleId : undefined}
getRootRef={tooltipRef}
style={mergeStyle(floatingStyle, styleProp)}
maxWidth={maxWidth}
Expand All @@ -158,8 +187,7 @@ export const OnboardingTooltip = ({
}
}
/>
<div className={styles.overlay} onClickCapture={onClose} />
</>,
</FocusTrap>,
tooltipContainer,
);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/vkui/src/components/OnboardingTooltip/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
> На странице не может быть два одновременно показанных тултипа. Они всегда должны показываться
> последовательно: следующий показывается при закрытии текущего и так до конца.

### Цифровая доступность (a11y)

`OnboardingTooltip` технически является модальным окном (`aria-role="dialog"`), а значит у него обязательно должно быть имя — его краткое название. Благодаря этому пользователи вспомогательных технологий знают, что это за элемент и какое у него содержимое.

Задать имя можно с помощью следующих способов:

- используя свойство `title`;
- используя свойство `aria-label`;
- используя свойство `aria-labelledby`;

### API

Если хочется снабдить какой-то элемент интерфейса подсказкой, достаточно просто «обернуть» его тултипом:
Expand Down
8 changes: 7 additions & 1 deletion packages/vkui/src/components/TooltipBase/TooltipBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export interface TooltipBaseProps
* Заголовок тултипа.
*/
title?: React.ReactNode;
/**
* [a11y] Id для заголовка тултипа.
* Можно использовать для связи элемента с `role="dialog"` и заголовка через `aria-labelledby`
*/
titleId?: string;
/**
* Для показа указателя, требуется передать хотя бы `coords` и `placement`.
*/
Expand Down Expand Up @@ -85,6 +90,7 @@ export const TooltipBase = ({
ArrowIcon = DefaultIcon,
description,
title,
titleId,
maxWidth = TOOLTIP_MAX_WIDTH,
closeIconLabel = 'Закрыть',
onCloseIconClick,
Expand All @@ -111,7 +117,7 @@ export const TooltipBase = ({
<div className={styles.content} style={maxWidth !== null ? { maxWidth } : undefined}>
<div>
{hasReactNode(title) && (
<Subhead className={styles.title} weight="2">
<Subhead id={titleId} className={styles.title} weight="2">
{title}
</Subhead>
)}
Expand Down