DashboardOutline: Improve markup semantics (#106008)

pull/106289/head^2
kay delaney 2 months ago committed by GitHub
parent cce28ec351
commit d9ccd879ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 4
      packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx
  3. 2
      packages/grafana-ui/src/components/Text/Text.tsx
  4. 113
      public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx

@ -1587,6 +1587,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx:5381": [ "public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx:5381": [ "public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],

@ -29,7 +29,7 @@ export const ElementSelectionContext = createContext<ElementSelectionContextStat
export interface UseElementSelectionResult { export interface UseElementSelectionResult {
isSelected?: boolean; isSelected?: boolean;
isSelectable?: boolean; isSelectable?: boolean;
onSelect?: (evt: React.PointerEvent, options?: ElementSelectionOnSelectOptions) => void; onSelect?: (evt: React.MouseEvent, options?: ElementSelectionOnSelectOptions) => void;
onClear?: () => void; onClear?: () => void;
} }
@ -45,7 +45,7 @@ export function useElementSelection(id: string | undefined): UseElementSelection
const isSelected = context.selected.some((item) => item.id === id); const isSelected = context.selected.some((item) => item.id === id);
const onSelect = useCallback( const onSelect = useCallback(
(evt: React.PointerEvent, options: ElementSelectionOnSelectOptions = {}) => { (evt: React.MouseEvent, options: ElementSelectionOnSelectOptions = {}) => {
if (!context.enabled) { if (!context.enabled) {
return; return;
} }

@ -11,7 +11,7 @@ import { customWeight, customColor, customVariant } from './utils';
export interface TextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'className' | 'style'> { export interface TextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'className' | 'style'> {
/** Defines what HTML element is defined underneath. "span" by default */ /** Defines what HTML element is defined underneath. "span" by default */
element?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p'; element?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p' | 'li';
/** What typograpy variant should be used for the component. Only use if default variant for the defined element is not what is needed */ /** What typograpy variant should be used for the component. Only use if default variant for the defined element is not what is needed */
variant?: keyof ThemeTypographyVariantTypes; variant?: keyof ThemeTypographyVariantTypes;
/** Override the default weight for the used variant */ /** Override the default weight for the used variant */

@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Trans, useTranslate } from '@grafana/i18n'; import { Trans, useTranslate } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes'; import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Text, useElementSelection, useStyles2, useTheme2 } from '@grafana/ui'; import { Box, Icon, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
@ -25,41 +25,40 @@ export function DashboardOutline({ editPane }: Props) {
const dashboard = getDashboardSceneFor(editPane); const dashboard = getDashboardSceneFor(editPane);
return ( return (
<Box padding={1} gap={0} display="flex" direction="column"> <Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} editPane={editPane} depth={0} /> <DashboardOutlineNode sceneObject={dashboard} editPane={editPane} depth={0} />
</Box> </Box>
); );
} }
function DashboardOutlineNode({ interface DashboardOutlineNodeProps {
sceneObject,
editPane,
depth,
}: {
sceneObject: SceneObject; sceneObject: SceneObject;
editPane: DashboardEditPane; editPane: DashboardEditPane;
depth: number; depth: number;
}) { }
const [isCollapsed, setIsCollapsed] = useState(depth > 0);
const theme = useTheme2(); function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutlineNodeProps) {
const { key } = sceneObject.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { key } = sceneObject.useState();
const [isCollapsed, setIsCollapsed] = useState(depth > 0);
const { isSelected, onSelect } = useElementSelection(key); const { isSelected, onSelect } = useElementSelection(key);
const isCloned = useMemo(() => isInCloneChain(key!), [key]); const isCloned = useMemo(() => isInCloneChain(key!), [key]);
const editableElement = useMemo(() => getEditableElementFor(sceneObject)!, [sceneObject]); const editableElement = useMemo(() => getEditableElementFor(sceneObject)!, [sceneObject]);
const { t } = useTranslate(); const { t } = useTranslate();
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const children = sortBy(collectEditableElementChildren(sceneObject, [], 0), 'depth'); const children = sortBy(collectEditableElementChildren(sceneObject, [], 0), 'depth');
const elementInfo = editableElement.getEditableElementInfo(); const elementInfo = editableElement.getEditableElementInfo();
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName; const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const elementCollapsed = editableElement.getCollapsedState?.(); const elementCollapsed = editableElement.getCollapsedState?.();
const outlineRename = useOutlineRename(editableElement); const outlineRename = useOutlineRename(editableElement);
const onNodeClicked = (evt: React.PointerEvent) => { const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
// Only select via clicking outline never deselect // Only select via clicking outline never deselect
if (!isSelected) { if (!isSelected) {
onSelect?.(evt); onSelect?.(e);
} }
editableElement.scrollIntoView?.(); editableElement.scrollIntoView?.();
@ -77,32 +76,34 @@ function DashboardOutlineNode({
// Sync canvas element expanded state with outline element // Sync canvas element expanded state with outline element
useEffect(() => { useEffect(() => {
if (elementCollapsed != null && elementCollapsed !== isCollapsed) { if (elementCollapsed === !isCollapsed) {
setIsCollapsed(elementCollapsed); setIsCollapsed(elementCollapsed);
} }
}, [isCollapsed, elementCollapsed]); }, [isCollapsed, elementCollapsed]);
return ( return (
<> // todo: add proper keyboard navigation
<div // eslint-disable-next-line jsx-a11y/click-events-have-key-events
className={cx(styles.container, isSelected && styles.containerSelected)} <li
style={{ paddingLeft: theme.spacing(depth * 3) }} role="treeitem"
onPointerDown={onNodeClicked} aria-selected={isSelected}
> className={styles.container}
onClick={onNodeClicked}
style={{ '--depth': depth } as React.CSSProperties}
>
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
<div className={styles.indentation}></div>
{elementInfo.isContainer && ( {elementInfo.isContainer && (
<button <button
// TODO fix keyboard a11y here
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
className={styles.angleButton} className={styles.angleButton}
onPointerDown={onToggleCollapse} onClick={onToggleCollapse}
data-testid={selectors.components.PanelEditor.Outline.node(instanceName)} data-testid={selectors.components.PanelEditor.Outline.node(instanceName)}
> >
<Icon name={!isCollapsed ? 'angle-down' : 'angle-right'} /> <Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
</button> </button>
)} )}
<button <button
className={cx(styles.nodeName, isCloned && styles.nodeNameClone)} className={cx(styles.nodeName, { [styles.nodeNameClone]: isCloned })}
onDoubleClick={outlineRename.onNameDoubleClicked} onDoubleClick={outlineRename.onNameDoubleClicked}
data-testid={selectors.components.PanelEditor.Outline.item(instanceName)} data-testid={selectors.components.PanelEditor.Outline.item(instanceName)}
> >
@ -128,8 +129,7 @@ function DashboardOutlineNode({
</div> </div>
{elementInfo.isContainer && !isCollapsed && ( {elementInfo.isContainer && !isCollapsed && (
<div className={styles.nodeChildren}> <ul className={styles.nodeChildren} role="group">
<div className={styles.nodeChildrenLine} style={{ marginLeft: theme.spacing(depth * 3) }} />
{children.length > 0 ? ( {children.length > 0 ? (
children.map((child) => ( children.map((child) => (
<DashboardOutlineNode <DashboardOutlineNode
@ -140,13 +140,13 @@ function DashboardOutlineNode({
/> />
)) ))
) : ( ) : (
<Text color="secondary"> <Text color="secondary" element="li">
<Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans> <Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans>
</Text> </Text>
)} )}
</div> </ul>
)} )}
</> </li>
); );
} }
@ -155,29 +155,35 @@ function getStyles(theme: GrafanaTheme2) {
container: css({ container: css({
display: 'flex', display: 'flex',
gap: theme.spacing(0.5), gap: theme.spacing(0.5),
alignItems: 'center',
flexGrow: 1, flexGrow: 1,
flexDirection: 'column',
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
position: 'relative',
marginBottom: theme.spacing(0.25),
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
'&:hover': {
color: theme.colors.text.primary,
outline: `1px dashed ${theme.colors.border.strong}`,
outlineOffset: '0px',
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.05),
},
}), }),
containerSelected: css({ containerSelected: css({
outline: `1px dashed ${theme.colors.primary.border} !important`, outline: `1px dashed ${theme.colors.primary.border} !important`,
outlineOffset: '0px', outlineOffset: '0px',
color: theme.colors.text.primary, color: theme.colors.text.primary,
}),
row: css({
display: 'flex',
gap: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
'&:hover': { '&:hover': {
outline: `1px dashed ${theme.colors.primary.border}`,
color: theme.colors.text.primary, color: theme.colors.text.primary,
outline: `1px dashed ${theme.colors.border.strong}`,
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.05),
}, },
}), }),
rowSelected: css({
color: theme.colors.text.primary,
outline: `1px dashed ${theme.colors.primary.border} !important`,
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.05),
}),
indentation: css({
marginLeft: `calc(var(--depth) * ${theme.spacing(3)})`,
}),
angleButton: css({ angleButton: css({
boxShadow: 'none', boxShadow: 'none',
border: 'none', border: 'none',
@ -191,7 +197,7 @@ function getStyles(theme: GrafanaTheme2) {
boxShadow: 'none', boxShadow: 'none',
border: 'none', border: 'none',
background: 'transparent', background: 'transparent',
padding: theme.spacing(0.25, 1, 0.25, 0), padding: 0,
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
color: 'inherit', color: 'inherit',
display: 'flex', display: 'flex',
@ -227,14 +233,19 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
}), gap: theme.spacing(0.5),
nodeChildrenLine: css({
position: 'absolute', // tree line
width: '1px', '&::before': {
height: '100%', content: '""',
left: '7px', position: 'absolute',
zIndex: 1, width: '1px',
backgroundColor: theme.colors.border.weak, height: '100%',
pointerEvents: 'none',
zIndex: 1,
background: theme.colors.border.weak,
marginLeft: `calc(11px + ${theme.spacing(3)} * var(--depth))`,
},
}), }),
}; };
} }

Loading…
Cancel
Save