Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions dashboards/src/components/Panel/Panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,18 +381,27 @@ describe('Panel', () => {
expect(screen.getByRole('banner')).toHaveTextContent('Stacks - All');
});

it('shows panel description', async () => {
it('shows panel description when clicked', async () => {
await renderPanel();

const descriptionButton = screen.getByRole('button', { name: /description/i });
expect(descriptionButton).toBeInTheDocument();
const descriptionButton = screen.getByRole('button', {
name: /description/i,
});

// Can hover to see panel description in tooltip
userEvent.hover(descriptionButton);
expect(descriptionButton).toHaveAttribute('aria-expanded', 'false');

await userEvent.click(descriptionButton);

const content = await screen.findByTestId('panel-description-content');

expect(content).toHaveTextContent('This is a fake panel - bar');
expect(descriptionButton).toHaveAttribute('aria-expanded', 'true');

const tooltip = await screen.findByTestId('panel-description-content');
expect(tooltip).toHaveTextContent('This is a fake panel - bar');
expect(descriptionButton.querySelector('svg')).toHaveAttribute('aria-describedby', 'info-tooltip');
const popover = screen.getByRole('dialog', {
name: 'Panel description',
});

expect(descriptionButton).toHaveAttribute('aria-controls', popover.id);
});

it('renders panel description as markdown', async () => {
Expand All @@ -404,7 +413,7 @@ describe('Panel', () => {
};
await renderPanel(panel);

userEvent.hover(screen.getByRole('button', { name: /description/i }));
userEvent.click(screen.getByRole('button', { name: /description/i }));

const content = await screen.findByTestId('panel-description-content');
expect(within(content).getByRole('heading', { name: 'Heading' })).toBeInTheDocument();
Expand All @@ -418,6 +427,32 @@ describe('Panel', () => {
expect(within(content).getByRole('link', { name: 'docs' })).toHaveAttribute('target', '_blank');
});

it('allows keyboard navigation to links in panel description', async () => {
const panel = createTestPanel();
panel.spec.display = {
name: 'Test Panel',
description: '[Docs](https://example.com)',
};

await renderPanel(panel);

const button = screen.getByRole('button', { name: /description/i });
button.focus();
await userEvent.keyboard('{Enter}');
const dialog = await screen.findByRole('dialog', { name: 'Panel description' });

const link = within(dialog).getByRole('link', { name: 'Docs' });
await userEvent.tab();

expect(link).toHaveFocus();

await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Panel description' })).not.toBeInTheDocument();
expect(button).toHaveFocus();
});
});

it('shows panel link', async () => {
await renderPanel();

Expand Down
116 changes: 77 additions & 39 deletions dashboards/src/components/Panel/PanelDescriptionTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import type { TooltipProps } from '@mui/material';
import { Box, styled, Tooltip, tooltipClasses } from '@mui/material';
import { Box, Popover, styled } from '@mui/material';
import type { DOMPurify as DOMPurifyInstance } from 'dompurify';
import DOMPurify from 'dompurify';
import { Marked } from 'marked';
import type { ReactElement, ReactNode } from 'react';
import { useMemo } from 'react';
import type { MouseEventHandler, ReactElement } from 'react';
import { cloneElement, useId, useMemo, useState } from 'react';

// Dedicated Marked instance, so that global `marked.use()` / `marked.setOptions()` calls elsewhere in the host
// application do not change how descriptions are rendered.
Expand Down Expand Up @@ -92,32 +91,89 @@ export function panelDescriptionToHTML(description: string): string {
});
}

type TriggerProps = {
onClick?: MouseEventHandler<HTMLElement>;
'aria-expanded'?: boolean;
'aria-controls'?: string;
'aria-haspopup'?: 'dialog';
};

export interface PanelDescriptionTooltipProps {
id?: string;
/**
* Panel description, written in Markdown.
*/
/** Panel description, written in Markdown. */
description: string;
children: ReactNode;
children: ReactElement<TriggerProps>;
/** Kept for compatibility; no delay in click-to-open mode. */
enterDelay?: number;
}

/**
* Tooltip displaying a panel description rendered as Markdown.
* The tooltip is interactive, so that links can be clicked and long descriptions can be scrolled.
* Displays a Markdown panel description in an accessible popover.
* Links are keyboard-accessible.
*/
export function PanelDescriptionTooltip({
id,
description,
children,
enterDelay,
}: PanelDescriptionTooltipProps): ReactElement {
const title = useMemo(() => <PanelDescriptionContent description={description} />, [description]);
export function PanelDescriptionTooltip({ id, description, children }: PanelDescriptionTooltipProps): ReactElement {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

const generatedId = useId();
const popoverId = id ?? generatedId;
const open = Boolean(anchorEl);

const handleClose = (): void => {
setAnchorEl(null);
};

const trigger = cloneElement(children, {
onClick: (event) => {
children.props.onClick?.(event);

if (event.defaultPrevented) {
return;
}

event.stopPropagation();

setAnchorEl(event.currentTarget);
},
'aria-expanded': open,
'aria-controls': open ? popoverId : undefined,
'aria-haspopup': 'dialog',
});

return (
<StyledTooltip arrow id={id} placement="top" title={title} enterDelay={enterDelay ?? 500} enterNextDelay={500}>
{/* Wrap in a span so that the tooltip always has a child that can hold a ref. */}
<span>{children}</span>
</StyledTooltip>
<>
{trigger}

<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
onClick={(event) => event.stopPropagation()}
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
slotProps={{
paper: {
id: popoverId,
role: 'dialog',
'aria-label': 'Panel description',
sx: (theme) => ({
backgroundColor: theme.palette.background.tooltip,
color: theme.palette.text.primary,
maxWidth: '500px',
padding: theme.spacing(1),
boxShadow: theme.shadows[1],
}),
},
}}
>
<PanelDescriptionContent description={description} />
</Popover>
</>
);
}

Expand Down Expand Up @@ -171,21 +227,3 @@ const MarkdownContent = styled(Box)(({ theme }) => ({
},
'& img': { maxWidth: '100%' },
}));

function PopperClassTooltip({ className, ...props }: TooltipProps): ReactElement {
const classes = useMemo(() => ({ popper: className }), [className]);
return <Tooltip {...props} classes={classes} />;
}

const StyledTooltip = styled(PopperClassTooltip)(({ theme }) => ({
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.background.tooltip,
color: theme.palette.text.primary,
maxWidth: '500px',
padding: theme.spacing(1),
boxShadow: theme.shadows[1],
},
[`& .${tooltipClasses.arrow}`]: {
color: theme.palette.background.tooltip,
},
}));
Loading