mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
CollapsableSection: Improves keyboard navigation and screen-reader support (#44005)
This commit is contained in:
parent
291d8aac7e
commit
3c1122cf29
@ -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',
|
||||
}),
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user