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

pull/44447/head
kay delaney 3 years ago committed by GitHub
parent 291d8aac7e
commit 3c1122cf29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 108
      packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
  2. 7
      public/app/features/search/components/SearchResults.test.tsx
  3. 25
      public/app/features/search/components/SearchResults.tsx
  4. 84
      public/app/features/search/components/SectionHeader.tsx

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

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

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

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

Loading…
Cancel
Save