From 3c1122cf29c4c1f6721cf6d2ad6009adad782c87 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Tue, 25 Jan 2022 15:36:17 +0000 Subject: [PATCH] CollapsableSection: Improves keyboard navigation and screen-reader support (#44005) --- .../Collapse/CollapsableSection.tsx | 108 ++++++++++++++---- .../search/components/SearchResults.test.tsx | 7 +- .../search/components/SearchResults.tsx | 25 ++-- .../search/components/SectionHeader.tsx | 84 ++++++++------ 4 files changed, 144 insertions(+), 80 deletions(-) diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index 3e2ac0adc99..89d2300b033 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -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 = ({ label, isOpen, onToggle, children }) => { +export const CollapsableSection: FC = ({ + label, + isOpen, + onToggle, + className, + contentClassName, + children, + labelId, + loading = false, +}) => { const [open, toggleOpen] = useState(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 ( -
-
- {label} - + <> +
+ +
+ {label} +
- {open &&
{children}
} -
+ {open && ( +
+ {children} +
+ )} + ); }; -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', + }), +}); diff --git a/public/app/features/search/components/SearchResults.test.tsx b/public/app/features/search/components/SearchResults.test.tsx index 1ca4924bb82..ba249df1811 100644 --- a/public/app/features/search/components/SearchResults.test.tsx +++ b/public/app/features/search/components/SearchResults.test.tsx @@ -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); }); diff --git a/public/app/features/search/components/SearchResults.tsx b/public/app/features/search/components/SearchResults.tsx index d99c2904d8c..bc5da39620f 100644 --- a/public/app/features/search/components/SearchResults.tsx +++ b/public/app/features/search/components/SearchResults.tsx @@ -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 = memo( const styles = getSectionStyles(theme); const itemProps = { editable, onToggleChecked, onTagSelected }; const renderFolders = () => { + const Wrapper = showPreviews ? SearchCard : SearchItem; return (
{results.map((section) => { return (
{section.title && ( - - )} - {section.expanded && - (showPreviews ? ( -
- {section.items.map((item) => ( - - ))} -
- ) : ( -
+ +
{section.items.map((item) => ( - + ))}
- ))} +
+ )}
); })} diff --git a/public/app/features/search/components/SectionHeader.tsx b/public/app/features/search/components/SectionHeader.tsx index 71c5d954f60..685babe2379 100644 --- a/public/app/features/search/components/SectionHeader.tsx +++ b/public/app/features/search/components/SectionHeader.tsx @@ -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 = ({ section, onSectionClick, + children, onToggleChecked, editable = false, }) => { @@ -33,49 +35,52 @@ export const SectionHeader: FC = ({ 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 ( -
- + contentClassName={styles.content} + loading={section.itemsFetching} + labelId={labelId} + label={ + <> + -
- -
+
+ +
-
- {section.title} - {section.url && ( - - | Go to folder - - )} -
- {section.itemsFetching ? : } -
+
+ {section.title} + {section.url && ( + + | Go to folder + + )} +
+ + } + > + {children} + ); }; @@ -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; + `, }; });