Repository: gravity-ui/uikit
Pull request #771 (2023-06-28 10:27:12)

feat(Card): added accessibility via keyboard
UXRFC-155

gravity-ui-bot: [Preview](https://preview.gravity-ui.com/uikit/771/) is ready.
<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/src/components/Card/Card.scss b/src/components/Card/Card.scss
index 764b05a0..0e91eb5d 100644
--- a/src/components/Card/Card.scss
+++ b/src/components/Card/Card.scss
@@ -9,14 +9,38 @@ $block: '.#{variables.$ns}card';
     box-shadow: var(--yc-card-box-shadow);
     box-sizing: border-box;
     border-radius: var(--yc-card-border-radius);
+    outline: none;
 
     &_type_action {
         background-color: var(--yc-color-base-float);
         --yc-card-box-shadow: 0px 1px 5px var(--yc-color-sfx-shadow);
 
-        &#{$block}_clickable:hover {
-            --yc-card-box-shadow: 0px 3px 10px var(--yc-color-sfx-shadow);
+        &::after {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            border-radius: var(--yc-card-border-radius);
+            pointer-events: none;
+        }
+
+        &#{$block}_clickable {
             cursor: pointer;
+            position: relative;
+
+            &:hover {
+                --yc-card-box-shadow: 0px 3px 10px var(--yc-color-sfx-shadow);
+            }
+
+            &:focus::after {
+                content: '';
+                box-shadow: 0 0 0 2px var(--yc-color-line-misc);
+            }
+
+            &:focus:not(:focus-visible)::after {
+                box-shadow: none;
+            }
         }
     }
 
@@ -34,13 +58,39 @@ $block: '.#{variables.$ns}card';
             pointer-events: none;
         }
 
-        &#{$block}_clickable:hover {
-            border-color: transparent;
+        &::after {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            border-radius: var(--yc-card-border-radius);
+            pointer-events: none;
+        }
+
+        &#{$block}_clickable {
             cursor: pointer;
 
-            &::before {
+            &:hover {
+                border-color: transparent;
+
+                &::before {
+                    content: '';
+                    border: 2px solid var(--yc-color-line-selection-hover);
+                }
+
+                &:focus-visible::before {
+                    border-color: transparent;
+                }
+            }
+
+            &:focus::after {
                 content: '';
-                border: 2px solid var(--yc-color-line-selection-hover);
+                box-shadow: 0 0 0 2px var(--yc-color-line-misc);
+            }
+
+            &:focus:not(:focus-visible)::after {
+                box-shadow: none;
             }
         }
 
diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx
index 8ec6d82d..9def9049 100644
--- a/src/components/Card/Card.tsx
+++ b/src/components/Card/Card.tsx
@@ -1,6 +1,8 @@
 import React from 'react';
 
+import type {QAProps} from '../types';
 import {block} from '../utils/cn';
+import {useActionHandlers} from '../utils/useActionHandlers';
 
 import './Card.scss';
 
@@ -14,7 +16,7 @@ export type CardTheme = 'normal' | 'info' | 'positive' | 'warning' | 'danger';
 export type CardView = SelectionCardView | ContainerCardView;
 export type CardSize = 'm' | 'l';
 
-export interface CardProps {
+export interface CardProps extends QAProps {
     children: React.ReactNode;
     style?: React.CSSProperties;
     className?: string;
@@ -46,7 +48,9 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(function Card(pr
         disabled,
         selected,
         style,
+        qa,
     } = props;
+
     const isTypeAction = type === 'action';
     const isTypeSelection = type === 'selection';
     const isTypeContainer = type === 'container';
@@ -60,11 +64,14 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(function Card(pr
     /* View only with type 'container' and 'selection' */
     const defaultView = isTypeContainer || isTypeSelection ? 'outlined' : undefined;
 
+    const handleClick = isClickable ? onClick : undefined;
+    const {onKeyDown} = useActionHandlers(handleClick);
+
     return (
-        // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
         <div
             style={style}
             ref={ref}
+            role={isClickable ? 'button' : undefined}
             className={b(
                 {
                     theme: theme || defaultTheme,
@@ -77,7 +84,10 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(function Card(pr
                 },
                 className,
             )}
-            onClick={isClickable ? onClick : undefined}
+            onClick={handleClick}
+            onKeyDown={onKeyDown}
+            tabIndex={isClickable ? 0 : undefined}
+            data-qa={qa}
         >
             {children}
         </div>
diff --git a/src/components/utils/useActionHandlers.ts b/src/components/utils/useActionHandlers.ts
new file mode 100644
index 00000000..5a0fbdd2
--- /dev/null
+++ b/src/components/utils/useActionHandlers.ts
@@ -0,0 +1,30 @@
+import React from 'react';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyFunction = (...args: any[]) => any;
+
+interface UseActionHandlersResult<T> {
+    onKeyDown: React.KeyboardEventHandler<T>;
+}
+
+/**
+ * Emulates behaviour of system controls, that respond to Enter and Spacebar
+ * @param callback
+ * @return {onKeyDown}
+ */
+export function useActionHandlers<T>(callback?: AnyFunction): UseActionHandlersResult<T> {
+    const onKeyDown = React.useCallback(
+        (event: React.KeyboardEvent<T>) => {
+            if (['Enter', ' ', 'Spacebar'].includes(event.key)) {
+                event.preventDefault();
+
+                return callback?.(event);
+            }
+
+            return undefined;
+        },
+        [callback],
+    );
+
+    return {onKeyDown};
+}


Test patch:
diff --git a/src/components/Card/__tests__/Card.test.tsx b/src/components/Card/__tests__/Card.test.tsx
new file mode 100644
index 00000000..c32fd4cd
--- /dev/null
+++ b/src/components/Card/__tests__/Card.test.tsx
@@ -0,0 +1,318 @@
+import React from 'react';
+
+import {render, screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {Button} from '../../Button';
+import {Card} from '../Card';
+import type {CardSize, CardTheme, CardType, CardView} from '../Card';
+
+const qaId = 'card-component';
+const cardText = 'Some text';
+
+const cardSizes: CardSize[] = ['l', 'm'];
+
+const cardThemes: CardTheme[] = ['danger', 'info', 'normal', 'positive', 'warning'];
+
+const cardTypes: CardType[] = ['action', 'container', 'selection'];
+
+const cardViews: CardView[] = ['outlined', 'clear', 'outlined', 'filled', 'raised', 'raised'];
+
+describe('Card', () => {
+    test('render card by default', () => {
+        render(<Card qa={qaId}>{cardText}</Card>);
+        const card = screen.getByText(cardText);
+
+        expect(card).toBeVisible();
+    });
+
+    test.each(cardThemes)('render with given "%s" theme', (theme) => {
+        render(
+            <Card theme={theme} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveClass(`yc-card_theme_${theme}`);
+    });
+
+    test.each(cardSizes)('render with given "%s" size', (size) => {
+        render(
+            <Card size={size} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveClass(`yc-card_size_${size}`);
+    });
+
+    test.each(cardViews)('render with given "%s" view', (view) => {
+        render(
+            <Card view={view} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveClass(`yc-card_view_${view}`);
+    });
+
+    test.each(cardTypes)('render with given "%s" type', (type) => {
+        render(
+            <Card type={type} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveClass(`yc-card_type_${type}`);
+    });
+
+    test('selected when selected=true prop is given', () => {
+        render(
+            <Card selected={true} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByText(cardText);
+
+        expect(card).toBeVisible();
+        expect(card).toHaveClass('yc-card_selected');
+    });
+
+    test('not selected when selected=false prop is given', () => {
+        render(
+            <Card selected={false} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByText(cardText);
+
+        expect(card).toBeVisible();
+        expect(card).not.toHaveClass('yc-card_selected');
+    });
+
+    test('disabled when disabled=true prop is given', () => {
+        render(
+            <Card disabled={true} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByText(cardText);
+
+        expect(card).toHaveClass('yc-card_disabled');
+    });
+
+    test('not disabled when disabled=false prop is given', () => {
+        render(
+            <Card disabled={false} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByText(cardText);
+
+        expect(card).not.toHaveClass('yc-card_disabled');
+    });
+
+    test('show given content', () => {
+        const content = 'Some content';
+
+        render(<Card>{content}</Card>);
+        const text = screen.getByText(content);
+
+        expect(text).toBeVisible();
+    });
+
+    test('show given children', () => {
+        const childrenText = 'Children content';
+
+        render(
+            <Card>
+                <span>{childrenText}</span>
+            </Card>,
+        );
+        const text = screen.getByText(childrenText);
+
+        expect(text).toBeVisible();
+    });
+
+    test('add className', () => {
+        const className = 'my-class';
+
+        render(
+            <Card className={className} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveClass(className);
+    });
+
+    test('add style', () => {
+        const style = {color: 'red'};
+
+        render(
+            <Card style={style} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(card).toHaveStyle(style);
+    });
+
+    test('use passed ref for component', () => {
+        const ref = React.createRef<HTMLDivElement>();
+
+        render(
+            <Card ref={ref} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+        const card = screen.getByTestId(qaId);
+
+        expect(ref.current).toBe(card);
+    });
+
+    test('call onClick if type="action"', async () => {
+        const user = userEvent.setup();
+        const handleOnClick = jest.fn();
+        render(
+            <Card type="action" onClick={handleOnClick} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+
+        const card = screen.getByText(cardText);
+
+        await user.click(card);
+        expect(handleOnClick).toHaveBeenCalledTimes(1);
+    });
+
+    test('call onClick if type="selection"', async () => {
+        const user = userEvent.setup();
+        const handleOnClick = jest.fn();
+        render(
+            <Card type="selection" onClick={handleOnClick} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+
+        const card = screen.getByText(cardText);
+
+        await user.click(card);
+        expect(handleOnClick).toHaveBeenCalledTimes(1);
+    });
+
+    test('ignore onClick if type!="container"', async () => {
+        const user = userEvent.setup();
+        const handleOnClick = jest.fn();
+        render(
+            <Card type="container" onClick={handleOnClick} qa={qaId}>
+                {cardText}
+            </Card>,
+        );
+
+        const card = screen.getByText(cardText);
+
+        await user.click(card);
+        expect(handleOnClick).toHaveBeenCalledTimes(0);
+    });
+
+    describe('should respond to clicks correctly', () => {
+        test('reacts to keystrokes if focused', async () => {
+            const user = userEvent.setup();
+            const handleOnClick = jest.fn();
+            render(
+                <Card type="action" onClick={handleOnClick} qa={qaId}>
+                    {cardText}
+                </Card>,
+            );
+
+            const card = screen.getByText(cardText);
+
+            card.focus();
+            await user.keyboard('[Space]');
+            await user.keyboard('[Enter]');
+
+            expect(handleOnClick).toHaveBeenCalledTimes(2);
+        });
+
+        test('ignores keystrokes if not focused', async () => {
+            const user = userEvent.setup();
+            const handleOnClick = jest.fn();
+            render(
+                <Card type="action" onClick={handleOnClick} qa={qaId}>
+                    {cardText}
+                </Card>,
+            );
+
+            const card = screen.getByText(cardText);
+
+            card.focus();
+
+            card.blur();
+
+            await user.keyboard('[Enter]');
+
+            expect(handleOnClick).toHaveBeenCalledTimes(0);
+        });
+
+        test('has the role of a button if type="action"', async () => {
+            const handleOnClick = jest.fn();
+            render(
+                <Card type="action" onClick={handleOnClick} qa={qaId}>
+                    {cardText}
+                </Card>,
+            );
+
+            const card = screen.getByRole('button');
+
+            expect(card).toBeDefined();
+        });
+
+        test('should be able to focus through the keyboard', async () => {
+            const user = userEvent.setup();
+            const handleOnClick = jest.fn();
+            render(
+                <Card type="action" onClick={handleOnClick} qa={qaId}>
+                    {cardText}
+                </Card>,
+            );
+
+            const card = screen.getByTestId(qaId);
+
+            await user.keyboard('[Tab]');
+
+            expect(card).toHaveFocus();
+        });
+
+        test('should take over the focus after the active element', async () => {
+            const user = userEvent.setup();
+            const handleOnClick = jest.fn();
+
+            const noopButtonQaId = 'noop-button';
+
+            render(
+                <React.Fragment>
+                    <Button qa={noopButtonQaId}>Some noop button</Button>
+                    <Card type="action" onClick={handleOnClick} qa={qaId}>
+                        {cardText}
+                    </Card>
+                </React.Fragment>,
+            );
+
+            const noopButton = screen.getByTestId(noopButtonQaId);
+            noopButton.focus();
+
+            const card = screen.getByTestId(qaId);
+
+            await user.keyboard('[Tab]');
+
+            expect(card).toHaveFocus();
+        });
+    });
+});