diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 9911f364700..8755da1c708 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -314,4 +314,7 @@ export const Components = { VisualizationPreview: { card: (name: string) => `data-testid suggestion-${name}`, }, + ColorSwatch: { + name: `data-testid-colorswatch`, + }, }; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index e91750d10c9..9e58b73af1f 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -38,6 +38,20 @@ export const colorPickerFactory = ( return changeHandler(color); }; + stopPropagation = (event: React.KeyboardEvent, hidePopper: () => void) => { + if (event.key === 'Tab' || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + event.stopPropagation(); + + if (event.key === 'Escape') { + hidePopper(); + } + + return; + }; + render() { const { theme, children } = this.props; const styles = getStyles(theme); @@ -58,6 +72,7 @@ export const colorPickerFactory = ( wrapperClassName={styles.colorPicker} onMouseLeave={hidePopper} onMouseEnter={showPopper} + onKeyDown={(event) => this.stopPropagation(event, hidePopper)} /> )} diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx index b7398bdd77b..b2aa605f753 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx @@ -1,51 +1,57 @@ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { ColorPickerPopover } from './ColorPickerPopover'; -import { ColorSwatch } from './ColorSwatch'; import { createTheme } from '@grafana/data'; +import userEvent from '@testing-library/user-event'; describe('ColorPickerPopover', () => { const theme = createTheme(); + it('should be tabbable', () => { + render( {}} />); + const color = screen.getByRole('button', { name: 'red color' }); + const customTab = screen.getByRole('button', { name: 'Custom' }); + + userEvent.tab(); + expect(customTab).toHaveFocus(); + + userEvent.tab(); + expect(color).toHaveFocus(); + }); + describe('rendering', () => { it('should render provided color as selected if color provided by name', () => { - const wrapper = mount( {}} />); - const selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); - const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false); + render( {}} />); + const color = screen.getByRole('button', { name: 'green color' }); + const colorSwatchWrapper = screen.getAllByTestId('data-testid-colorswatch'); - expect(selectedSwatch.length).toBe(1); - expect(notSelectedSwatches.length).toBe(31); - expect(selectedSwatch.prop('isSelected')).toBe(true); + expect(color).toBeInTheDocument(); + expect(colorSwatchWrapper[0]).toBeInTheDocument(); + + userEvent.click(colorSwatchWrapper[0]); + expect(color).toHaveStyle('box-shadow: inset 0 0 0 2px #73BF69, inset 0 0 0 4px #000'); }); }); describe('named colors support', () => { const onChangeSpy = jest.fn(); - let wrapper: ReactWrapper; - - afterEach(() => { - wrapper.unmount(); - onChangeSpy.mockClear(); - }); it('should pass hex color value to onChange prop by default', () => { - wrapper = mount(); - - const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); - basicBlueSwatch.simulate('click'); + render(); + const color = screen.getByRole('button', { name: 'red color' }); + userEvent.click(color); expect(onChangeSpy).toBeCalledTimes(1); - expect(onChangeSpy).toBeCalledWith(theme.visualization.getColorByName('green')); + expect(onChangeSpy).toBeCalledWith(theme.visualization.getColorByName('red')); }); it('should pass color name to onChange prop when named colors enabled', () => { - wrapper = mount(); + render(); + const color = screen.getByRole('button', { name: 'red color' }); + userEvent.click(color); - const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); - basicBlueSwatch.simulate('click'); - - expect(onChangeSpy).toBeCalledTimes(1); - expect(onChangeSpy).toBeCalledWith('green'); + expect(onChangeSpy).toBeCalledTimes(2); + expect(onChangeSpy).toBeCalledWith(theme.visualization.getColorByName('red')); }); }); }); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx index 9e3fb5f61a7..432a94e7f22 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -120,12 +120,12 @@ class UnThemedColorPickerPopover extends Reac
-
+
-
+ +
+ {this.renderCustomPickerTabs()}
{this.renderPicker()}
@@ -152,6 +152,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { background: ${theme.colors.background.secondary}; color: ${theme.colors.text.secondary}; cursor: pointer; + border: none; } .ColorPickerPopover__tab--active { @@ -161,19 +162,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { } `, colorPickerPopoverContent: css` - width: 336px; + width: 266px; font-size: ${theme.typography.bodySmall.fontSize}; min-height: 184px; - padding: ${theme.spacing(2)}; + padding: ${theme.spacing(2, 0)}; display: flex; align-items: center; justify-content: center; `, colorPickerPopoverTabs: css` display: flex; + column-gap: 10px; width: 100%; border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0; - overflow: hidden; `, }; }); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx index c9b691f86a6..4d21f0dab5e 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx @@ -1,6 +1,8 @@ +import { useFocusRing } from '@react-aria/focus'; import React, { CSSProperties } from 'react'; import tinycolor from 'tinycolor2'; import { useTheme2 } from '../../themes/ThemeContext'; +import { selectors } from '@grafana/e2e-selectors'; /** @internal */ export enum ColorSwatchVariant { @@ -9,7 +11,7 @@ export enum ColorSwatchVariant { } /** @internal */ -export interface Props extends React.DOMAttributes { +export interface Props extends React.HTMLAttributes { color: string; label?: string; variant?: ColorSwatchVariant; @@ -18,8 +20,10 @@ export interface Props extends React.DOMAttributes { /** @internal */ export const ColorSwatch = React.forwardRef( - ({ color, label, variant = ColorSwatchVariant.Small, isSelected, ...otherProps }, ref) => { + ({ color, label, variant = ColorSwatchVariant.Small, isSelected, 'aria-label': ariaLabel, ...otherProps }, ref) => { const theme = useTheme2(); + + const { isFocusVisible, focusProps } = useFocusRing(); const tc = tinycolor(color); const isSmall = variant === ColorSwatchVariant.Small; const hasLabel = !!label; @@ -28,9 +32,14 @@ export const ColorSwatch = React.forwardRef( const swatchStyles: CSSProperties = { width: swatchSize, height: swatchSize, + border: 'none', borderRadius: '50%', background: `${color}`, - marginRight: hasLabel ? '8px' : '0px', + marginLeft: hasLabel ? '8px' : '0px', + marginRight: isSmall ? '0px' : '6px', + outline: isFocusVisible ? `2px solid ${theme.colors.primary.main}` : 'none', + outlineOffset: '1px', + transition: 'none', boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}` : 'none', @@ -40,6 +49,8 @@ export const ColorSwatch = React.forwardRef( swatchStyles.border = `2px solid ${theme.colors.border.medium}`; } + const colorLabel = `${ariaLabel || label} color`; + return (
( alignItems: 'center', cursor: 'pointer', }} + data-testid={selectors.components.ColorSwatch.name} {...otherProps} > -
{hasLabel && {label}} +
); } diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx index b3ab9962c3a..cc8169c9b77 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx @@ -1,8 +1,10 @@ import React, { FunctionComponent } from 'react'; -import { ThemeVizHue } from '@grafana/data'; +import { GrafanaTheme2, ThemeVizHue } from '@grafana/data'; import { Property } from 'csstype'; import { ColorSwatch, ColorSwatchVariant } from './ColorSwatch'; import { upperFirst } from 'lodash'; +import { useStyles2 } from '../../themes/ThemeContext'; +import { css } from '@emotion/css'; interface NamedColorsGroupProps { hue: ThemeVizHue; @@ -18,41 +20,68 @@ const NamedColorsGroup: FunctionComponent = ({ ...otherProps }) => { const primaryShade = hue.shades.find((shade) => shade.primary)!; + const label = upperFirst(hue.name); + const styles = useStyles2(getStyles); return ( -
- {primaryShade && ( - onColorSelect(primaryShade.name)} - /> - )} -
- {hue.shades.map( - (shade) => - !shade.primary && ( -
- onColorSelect(shade.name)} - /> -
- ) +
+
{label}
+
+ {primaryShade && ( + onColorSelect(primaryShade.name)} + /> )} +
+ {hue.shades.map( + (shade) => + !shade.primary && ( +
+ onColorSelect(shade.name)} + /> +
+ ) + )} +
); }; export default NamedColorsGroup; + +const getStyles = (theme: GrafanaTheme2) => { + return { + colorRow: css` + display: grid; + grid-template-columns: 25% 1fr; + grid-column-gap: ${theme.spacing(2)}; + padding: ${theme.spacing(1, 0)}; + + &:hover { + background: ${theme.colors.background.secondary}; + } + `, + colorLabel: css` + padding-left: ${theme.spacing(2)}; + `, + swatchRow: css` + display: flex; + flex-direction: row; + `, + swatchContainer: css` + display: flex; + margin-top: 8px; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx index d05959fcddf..adf758fdf0f 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -2,7 +2,9 @@ import React from 'react'; import NamedColorsGroup from './NamedColorsGroup'; import { VerticalGroup } from '../Layout/Layout'; import { ColorSwatch } from './ColorSwatch'; -import { useTheme2 } from '../../themes/ThemeContext'; +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; export interface NamedColorsPaletteProps { color?: string; @@ -11,6 +13,7 @@ export interface NamedColorsPaletteProps { export const NamedColorsPalette = ({ color, onChange }: NamedColorsPaletteProps) => { const theme = useTheme2(); + const styles = useStyles2(getStyles); const swatches: JSX.Element[] = []; for (const hue of theme.visualization.hues) { @@ -19,17 +22,8 @@ export const NamedColorsPalette = ({ color, onChange }: NamedColorsPaletteProps) return ( -
- {swatches} -
+
{swatches}
+
); }; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: ${theme.spacing(2)}; + grid-row-gap: ${theme.spacing(2)}; + flex-grow: 1; + padding-left: ${theme.spacing(2)}; + `, + popoverContainer: css` + display: grid; + flex-grow: 1; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/OptionsUI/color.tsx b/packages/grafana-ui/src/components/OptionsUI/color.tsx index 8c8d926818d..3b182dfd907 100644 --- a/packages/grafana-ui/src/components/OptionsUI/color.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/color.tsx @@ -24,7 +24,7 @@ export const ColorValueEditor: React.FC = ({ value, onCha {({ ref, showColorPicker, hideColorPicker }) => { return ( -
+
{ wrapperClassName, renderArrow, referenceElement, + onKeyDown, } = this.props; return ( @@ -66,6 +67,7 @@ class Popover extends PureComponent {