CollapsableSection: Improves keyboard navigation and screen-reader support (#44005)

This commit is contained in:
kay delaney 2022-01-25 15:36:17 +00:00 committed by GitHub
parent 291d8aac7e
commit 3c1122cf29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 146 additions and 82 deletions

View File

@ -1,8 +1,10 @@
import React, { FC, ReactNode, useState } from 'react';
import { css } from '@emotion/css';
import React, { FC, ReactNode, useRef, useState } from 'react';
import { uniqueId } from 'lodash';
import { css, cx } from '@emotion/css';
import { useStyles2 } from '../../themes';
import { Icon } from '..';
import { Icon, Spinner } from '..';
import { GrafanaTheme2 } from '@grafana/data';
import { getFocusStyles } from '../../themes/mixins';
export interface Props {
label: ReactNode;
@ -10,46 +12,102 @@ export interface Props {
/** Callback for the toggle functionality */
onToggle?: (isOpen: boolean) => void;
children: ReactNode;
className?: string;
contentClassName?: string;
loading?: boolean;
labelId?: string;
}
export const CollapsableSection: FC<Props> = ({ label, isOpen, onToggle, children }) => {
export const CollapsableSection: FC<Props> = ({
label,
isOpen,
onToggle,
className,
contentClassName,
children,
labelId,
loading = false,
}) => {
const [open, toggleOpen] = useState<boolean>(isOpen);
const styles = useStyles2(collapsableSectionStyles);
const headerStyle = open ? styles.header : styles.headerCollapsed;
const tooltip = `Click to ${open ? 'collapse' : 'expand'}`;
const onClick = () => {
const onClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggle?.(!open);
toggleOpen(!open);
};
const { current: id } = useRef(uniqueId());
const buttonLabelId = labelId ?? `collapse-label-${id}`;
return (
<div>
<div onClick={onClick} className={headerStyle} title={tooltip}>
{label}
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" className={styles.icon} />
<>
<div onClick={onClick} className={cx(styles.header, className)} title={tooltip}>
<button
id={`collapse-button-${id}`}
className={styles.button}
onClick={onClick}
aria-expanded={open && !loading}
aria-controls={`collapse-content-${id}`}
aria-labelledby={buttonLabelId}
>
{loading ? (
<Spinner className={styles.spinner} />
) : (
<Icon name={open ? 'angle-down' : 'angle-right'} className={styles.icon} />
)}
</button>
<div className={styles.label} id={`collapse-label-${id}`}>
{label}
</div>
</div>
{open && <div className={styles.content}>{children}</div>}
</div>
{open && (
<div id={`collapse-content-${id}`} className={cx(styles.content, contentClassName)}>
{children}
</div>
)}
</>
);
};
const collapsableSectionStyles = (theme: GrafanaTheme2) => {
const header = css({
const collapsableSectionStyles = (theme: GrafanaTheme2) => ({
header: css({
display: 'flex',
cursor: 'pointer',
boxSizing: 'border-box',
flexDirection: 'row-reverse',
position: 'relative',
justifyContent: 'space-between',
fontSize: theme.typography.size.lg,
padding: `${theme.spacing(0.5)} 0`,
cursor: 'pointer',
});
const headerCollapsed = css(header, {
'&:focus-within': getFocusStyles(theme),
}),
headerClosed: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
});
const icon = css({
}),
button: css({
all: 'unset',
'&:focus-visible': {
outline: 'none',
outlineOffset: 'unset',
transition: 'none',
boxShadow: 'none',
},
}),
icon: css({
color: theme.colors.text.secondary,
});
const content = css({
}),
content: css({
padding: `${theme.spacing(2)} 0`,
});
return { header, headerCollapsed, icon, content };
};
}),
spinner: css({
display: 'flex',
alignItems: 'center',
width: theme.v1.spacing.md,
}),
label: css({
display: 'flex',
}),
});

View File

@ -32,7 +32,7 @@ describe('SearchResults', () => {
it('should render section items for expanded section', () => {
setup();
expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument();
expect(screen.getAllByText('General', { exact: false })[0]).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.itemsV2)).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 1'))).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 2'))).toBeInTheDocument();
@ -45,7 +45,7 @@ describe('SearchResults', () => {
it('should render search card items for expanded section when showPreviews is enabled', () => {
setup({ showPreviews: true });
expect(screen.getByTestId(selectors.components.Search.collapseFolder('0'))).toBeInTheDocument();
expect(screen.getAllByText('General', { exact: false })[0]).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.cards)).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 1'))).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 2'))).toBeInTheDocument();
@ -70,8 +70,7 @@ describe('SearchResults', () => {
const mockOnToggleSection = jest.fn();
setup({ onToggleSection: mockOnToggleSection });
fireEvent.click(screen.getByTestId(selectors.components.Search.collapseFolder('0')));
expect(mockOnToggleSection).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByText('General', { exact: false })[0]);
expect(mockOnToggleSection).toHaveBeenCalledWith(generalFolder);
});

View File

@ -1,6 +1,5 @@
import React, { FC, memo } from 'react';
import { css } from '@emotion/css';
import classNames from 'classnames';
import { css, cx } from '@emotion/css';
import { FixedSizeList, FixedSizeGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme } from '@grafana/data';
@ -31,28 +30,24 @@ export const SearchResults: FC<Props> = memo(
const styles = getSectionStyles(theme);
const itemProps = { editable, onToggleChecked, onTagSelected };
const renderFolders = () => {
const Wrapper = showPreviews ? SearchCard : SearchItem;
return (
<div className={styles.wrapper}>
{results.map((section) => {
return (
<div data-testid={sectionLabel} className={styles.section} key={section.id || section.title}>
{section.title && (
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }}>
<div
data-testid={showPreviews ? cardsLabel : itemsLabel}
className={cx(styles.sectionItems, { [styles.gridContainer]: showPreviews })}
>
{section.items.map((item) => (
<Wrapper {...itemProps} key={item.uid} item={item} />
))}
</div>
</SectionHeader>
)}
{section.expanded &&
(showPreviews ? (
<div data-testid={cardsLabel} className={classNames(styles.sectionItems, styles.gridContainer)}>
{section.items.map((item) => (
<SearchCard {...itemProps} key={item.uid} item={item} />
))}
</div>
) : (
<div data-testid={itemsLabel} className={styles.sectionItems}>
{section.items.map((item) => (
<SearchItem key={item.id} {...itemProps} item={item} />
))}
</div>
))}
</div>
);
})}

View File

@ -2,23 +2,25 @@ import React, { FC, useCallback } from 'react';
import { css, cx } from '@emotion/css';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Icon, Spinner, stylesFactory, useTheme } from '@grafana/ui';
import { CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui';
import { DashboardSection, OnToggleChecked } from '../types';
import { SearchCheckbox } from './SearchCheckbox';
import { getSectionIcon, getSectionStorageKey } from '../utils';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
interface SectionHeaderProps {
editable?: boolean;
onSectionClick: (section: DashboardSection) => void;
onToggleChecked?: OnToggleChecked;
section: DashboardSection;
children: React.ReactNode;
}
export const SectionHeader: FC<SectionHeaderProps> = ({
section,
onSectionClick,
children,
onToggleChecked,
editable = false,
}) => {
@ -33,49 +35,52 @@ export const SectionHeader: FC<SectionHeaderProps> = ({
const handleCheckboxClick = useCallback(
(ev: React.MouseEvent) => {
console.log('section header handleCheckboxClick');
ev.stopPropagation();
ev.preventDefault();
if (onToggleChecked) {
onToggleChecked(section);
}
onToggleChecked?.(section);
},
[onToggleChecked, section]
);
const id = useUniqueId();
const labelId = `section-header-label-${id}`;
return (
<div
<CollapsableSection
isOpen={section.expanded ?? false}
onToggle={onSectionExpand}
className={styles.wrapper}
onClick={onSectionExpand}
data-testid={
section.expanded
? selectors.components.Search.collapseFolder(section.id?.toString())
: selectors.components.Search.expandFolder(section.id?.toString())
contentClassName={styles.content}
loading={section.itemsFetching}
labelId={labelId}
label={
<>
<SearchCheckbox
className={styles.checkbox}
editable={editable}
checked={section.checked}
onClick={handleCheckboxClick}
aria-label="Select folder"
/>
<div className={styles.icon}>
<Icon name={getSectionIcon(section)} />
</div>
<div className={styles.text}>
<span id={labelId}>{section.title}</span>
{section.url && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" /> Go to folder
</a>
)}
</div>
</>
}
>
<SearchCheckbox
className={styles.checkbox}
editable={editable}
checked={section.checked}
onClick={handleCheckboxClick}
aria-label="Select folder"
/>
<div className={styles.icon}>
<Icon name={getSectionIcon(section)} />
</div>
<div className={styles.text}>
{section.title}
{section.url && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" /> Go to folder
</a>
)}
</div>
{section.itemsFetching ? <Spinner /> : <Icon name={section.expanded ? 'angle-down' : 'angle-right'} />}
</div>
{children}
</CollapsableSection>
);
};
@ -84,18 +89,21 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa
return {
wrapper: cx(
css`
display: flex;
align-items: center;
font-size: ${theme.typography.size.base};
padding: 12px;
border-bottom: none;
color: ${theme.colors.textWeak};
z-index: 1;
&:hover,
&.selected {
color: ${theme.colors.text};
}
&:hover {
&:hover,
&:focus-visible,
&:focus-within {
a {
opacity: 1;
}
@ -123,5 +131,9 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa
separator: css`
margin-right: 6px;
`,
content: css`
padding-top: 0px;
padding-bottom: 0px;
`,
};
});