mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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 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
|
||||||
|
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>
|
</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}
|
||||||
|
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
|
{children}
|
||||||
className={styles.checkbox}
|
</CollapsableSection>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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…
Reference in New Issue
Block a user