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
14 changes: 10 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"@gravity-ui/react-data-table": "^2.2.1",
"@gravity-ui/react-unipika": "^0.5.2",
"@gravity-ui/table": "^1.10.1",
"@gravity-ui/uikit": "^7.31.1",
"@gravity-ui/uikit": "^7.48.0",
"@gravity-ui/unipika": "^5.2.1",
"@gravity-ui/websql-autocomplete": "^13.15.0",
"@hookform/resolvers": "^5.4.0",
Expand Down
3 changes: 2 additions & 1 deletion src/components/Drawer/Drawer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@

height: 100%;
}
&__click-handler {
&__click-handler,
&__event-boundary {
display: contents;
}
}
115 changes: 87 additions & 28 deletions src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type DrawerEvent = MouseEvent & {
};

interface DrawerPaneContentWrapperProps {
labelledBy?: string;
isVisible: boolean;
onClose: () => void;
onTransitionInComplete?: () => void;
Expand All @@ -40,6 +41,7 @@ interface DrawerPaneContentWrapperProps {
defaultWidth?: number;
isPercentageWidth?: boolean;
hideVeil?: boolean;
disableModal?: boolean;
}

const DrawerPaneContentWrapper = ({
Expand All @@ -55,11 +57,38 @@ const DrawerPaneContentWrapper = ({
detectClickOutside = false,
isPercentageWidth,
hideVeil = true,
disableModal = false,
labelledBy,
}: DrawerPaneContentWrapperProps) => {
const [savedWidthString, setSavedWidthString] = useSetting<string | undefined>(storageKey);
const [userDrawerWidth, setUserDrawerWidth] = React.useState<number | undefined>(undefined);

const drawerRef = React.useRef<HTMLDivElement>(null);
const openerRef = React.useRef<HTMLElement | null>(null);

// Floating UI suppresses return focus after focus-out, even if the drawer stays open.
React.useLayoutEffect(() => {
if (!disableModal) {
return;
}
const activeElement = document.activeElement;
if (isVisible) {
if (
activeElement instanceof HTMLElement &&
!drawerRef.current?.contains(activeElement)
) {
openerRef.current = activeElement;
}
} else {
if (
(drawerRef.current?.contains(activeElement) || activeElement === document.body) &&
openerRef.current?.isConnected
) {
openerRef.current.focus();
}
openerRef.current = null;
}
}, [disableModal, isVisible]);
const {containerWidth, itemContainerRef, rightInset, visibleRightInset} =
useDrawerContextInternal();
const availableWidth = Math.max(0, containerWidth - visibleRightInset);
Expand Down Expand Up @@ -150,49 +179,72 @@ const DrawerPaneContentWrapper = ({
[containerWidth, isPercentageWidth, saveWidthDebounced],
);

const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
const handleOpenChange = React.useCallback<
NonNullable<React.ComponentProps<typeof GravityDrawer>['onOpenChange']>
>(
(open, _event, reason) => {
if (!open && !(disableModal && reason === 'focus-out')) {
onClose();
}
},
[onClose],
[disableModal, onClose],
);

const handleClickInsideDrawer = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const nativeEvent = event.nativeEvent as DrawerEvent;
nativeEvent._capturedInsideDrawer = true;
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
disableModal &&
event.key === 'Escape' &&
!event.defaultPrevented &&
!event.nativeEvent.isComposing &&
event.target instanceof Element &&
event.target.closest('[role="dialog"]') === drawerRef.current
) {
event.preventDefault();
event.stopPropagation();
onClose();
}
};

const itemContainer = itemContainerRef?.current;
if (!itemContainer) {
return null;
}

return (
<GravityDrawer
qa={drawerId}
open={isVisible}
onOpenChange={handleOpenChange}
onTransitionInComplete={onTransitionInComplete}
placement={direction}
hideVeil={hideVeil}
className={b('container', className)}
contentClassName={b('item')}
style={drawerOverlayStyle}
container={itemContainer}
resizable
maxSize={availableWidth}
size={calculatedWidth}
onResizeEnd={handleResizeDrawer}
disableBodyScrollLock
disableOutsideClick={detectClickOutside}
floatingRef={detectClickOutside ? drawerRef : undefined}
>
<div className={b('click-handler')} onClickCapture={handleClickInsideDrawer}>
{children}
</div>
</GravityDrawer>
<div className={b('event-boundary')} onKeyDown={handleKeyDown}>
<GravityDrawer
qa={drawerId}
open={isVisible}
onOpenChange={handleOpenChange}
onTransitionInComplete={onTransitionInComplete}
placement={direction}
hideVeil={hideVeil}
className={b('container', className)}
contentClassName={b('item')}
style={drawerOverlayStyle}
container={itemContainer}
resizable
maxSize={availableWidth}
size={calculatedWidth}
onResizeEnd={handleResizeDrawer}
aria-labelledby={labelledBy}
disableModal={disableModal}
disableEscapeKeyDown={disableModal}
disableBodyScrollLock
disableOutsideClick={detectClickOutside}
floatingRef={drawerRef}
returnFocus={disableModal ? false : undefined}
>
<div className={b('click-handler')} onClickCapture={handleClickInsideDrawer}>
{children}
</div>
</GravityDrawer>
</div>
);
};

Expand All @@ -218,6 +270,7 @@ interface DrawerPaneProps {
title?: React.ReactNode;
headerClassName?: string;
hideVeil?: boolean;
disableModal?: boolean;
}

export const DrawerWrapper = ({
Expand All @@ -237,7 +290,9 @@ export const DrawerWrapper = ({
title,
headerClassName,
hideVeil,
disableModal,
}: DrawerPaneProps) => {
const titleId = React.useId();
const onCloseDrawerRef = React.useRef(onCloseDrawer);

React.useEffect(() => {
Expand Down Expand Up @@ -281,7 +336,9 @@ export const DrawerWrapper = ({
alignItems="center"
className={b('header-wrapper', headerClassName)}
>
<Text variant="subheader-2">{title}</Text>
<Text id={titleId} variant="subheader-2">
{title}
</Text>
<Flex className={b('controls')}>{controls}</Flex>
</Flex>
);
Expand All @@ -292,6 +349,8 @@ export const DrawerWrapper = ({
{children}
<DrawerPaneContentWrapper
hideVeil={hideVeil}
disableModal={disableModal}
labelledBy={title ? titleId : undefined}
isVisible={isDrawerVisible}
onClose={onCloseDrawer}
onTransitionInComplete={onTransitionInComplete}
Expand Down
99 changes: 99 additions & 0 deletions src/components/Drawer/__test__/DrawerAccessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';

import {Drawer, ThemeProvider} from '@gravity-ui/uikit';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';

import {DrawerWrapper} from '../Drawer';
import {DrawerContextProvider} from '../DrawerContext';

jest.mock('../../../utils/hooks/useSetting', () => ({
useSetting: () => [undefined, jest.fn()],
}));

function Fixture() {
const [healthcheck, setHealthcheck] = React.useState(false);
const [assistant, setAssistant] = React.useState(false);
return (
<ThemeProvider theme="light">
<DrawerContextProvider>
<DrawerWrapper
title="Healthcheck"
isDrawerVisible={healthcheck}
onCloseDrawer={() => setHealthcheck(false)}
drawerControls={[{type: 'close'}]}
disableModal
renderDrawerContent={() => <input aria-label="Healthcheck filter" />}
>
<button onClick={() => setHealthcheck(true)}>Open Healthcheck</button>
<button onClick={() => setAssistant(true)}>Open assistant</button>
</DrawerWrapper>
</DrawerContextProvider>
<Drawer
open={assistant}
disableModal
hideVeil
aria-label="Assistant"
disableEscapeKeyDown
>
<button onClick={() => setAssistant(false)}>Close assistant</button>
</Drawer>
</ThemeProvider>
);
}

const originalResizeObserver = window.ResizeObserver;
beforeEach(() => {
window.ResizeObserver = jest.fn(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
width: 1400,
height: 800,
right: 1400,
bottom: 800,
toJSON() {},
});
});
afterEach(() => {
window.ResizeObserver = originalResizeObserver;
jest.restoreAllMocks();
});

test('returns focus to its opener after visiting the adjacent panel', async () => {
render(<Fixture />);
const opener = screen.getByRole('button', {name: 'Open Healthcheck'});
opener.focus();
fireEvent.click(opener);
const healthcheck = await screen.findByRole('dialog', {name: 'Healthcheck'});
const assistantOpener = screen.getByRole('button', {name: 'Open assistant'});
assistantOpener.focus();
fireEvent.click(assistantOpener);
await screen.findByRole('dialog', {name: 'Assistant'});
within(healthcheck).getByRole('textbox').focus();
fireEvent.keyDown(within(healthcheck).getByRole('textbox'), {key: 'Escape'});
await waitFor(() => expect(opener).toHaveFocus());
expect(screen.getByRole('dialog', {name: 'Assistant'})).toBeInTheDocument();
});

test('does not steal focus from an adjacent panel when closed externally', async () => {
render(<Fixture />);
const opener = screen.getByRole('button', {name: 'Open Healthcheck'});
opener.focus();
fireEvent.click(opener);
const healthcheck = await screen.findByRole('dialog', {name: 'Healthcheck'});
fireEvent.click(screen.getByRole('button', {name: 'Open assistant'}));
const assistantClose = await screen.findByRole('button', {name: 'Close assistant'});
await waitFor(() => expect(screen.getByRole('dialog', {name: 'Assistant'})).toHaveFocus());
assistantClose.focus();
fireEvent.click(within(healthcheck).getByRole('button', {name: 'Close'}));
await waitFor(() =>
expect(screen.queryByRole('dialog', {name: 'Healthcheck'})).not.toBeInTheDocument(),
);
expect(assistantClose).toHaveFocus();
});
7 changes: 4 additions & 3 deletions src/components/Search/__test__/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ThemeProvider} from '@gravity-ui/uikit';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Expand All @@ -16,7 +17,7 @@ describe('Search', () => {
const onChange = jest.fn();
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});

render(<Search onChange={onChange} value="" />);
render(<Search onChange={onChange} value="" />, {wrapper: ThemeProvider});

const input = screen.getByRole('textbox');
await user.type(input, ' hello ');
Expand All @@ -31,7 +32,7 @@ describe('Search', () => {
const onChange = jest.fn();
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});

render(<Search onChange={onChange} value="" />);
render(<Search onChange={onChange} value="" />, {wrapper: ThemeProvider});

const input = screen.getByRole('textbox');
await user.type(input, 'hello world');
Expand All @@ -45,7 +46,7 @@ describe('Search', () => {
const onChange = jest.fn();
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});

render(<Search onChange={onChange} value="" />);
render(<Search onChange={onChange} value="" />, {wrapper: ThemeProvider});

const input = screen.getByRole('textbox');
await user.type(input, ' ');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function HealthcheckDrawer({
renderDrawerContent={renderContentWithExtension}
drawerId={drawerId}
storageKey={storageKey}
disableModal={uiFactory.healthcheck.disableModal}
detectClickOutside
hideVeil={false}
isPercentageWidth
Expand Down
Loading
Loading