Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the actual opener for every healthcheck drawer

When disableModal is enabled in the cluster or database-list views on WebKit, a pointer click on a status button/interactive label does not move focus, so the layout effect records the previously focused element rather than the control that opened the drawer. The workaround in HealthcheckPreview.openHealthcheck covers only tenant-page openers, while Cluster.tsx and DatabaseDrawerHealthcheck.tsx also reach this globally configured HealthcheckDrawer; closing those drawers therefore restores focus to the wrong element. Capture/pass the opener generically or apply the focus workaround to every healthcheck opener.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

detectClickOutside
hideVeil={false}
isPercentageWidth
Expand Down
Loading
Loading