diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoFinallyNode.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoFinallyNode.tsx index e2fc316f..1e76c80c 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoFinallyNode.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoFinallyNode.tsx @@ -23,7 +23,12 @@ const DemoFinallyNode: React.FunctionComponent = ({ ...pro return ( - + ); diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx index bd5a65d9..667602c5 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx @@ -7,6 +7,7 @@ import { WithDragNodeProps, WithSelectionProps } from '@patternfly/react-topology'; +import { DEFAULT_TASK_HEIGHT, DEFAULT_TASK_WIDTH } from './useDemoPipelineNodes'; type DemoPipelinesGroupProps = { element: GraphElement; @@ -14,17 +15,21 @@ type DemoPipelinesGroupProps = { WithDragNodeProps & WithSelectionProps; -const DemoPipelinesGroup: React.FunctionComponent = ({ element }) => { +const DemoPipelinesGroup: React.FunctionComponent = ({ element, ...rest }) => { const data = element.getData(); return ( ); }; diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx index d21fb405..6def3fb5 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx @@ -76,6 +76,7 @@ const DemoTaskNode: React.FunctionComponent = ({ { const pipelineNodes = useDemoPipelineNodes( pipelineOptions.showContextMenus, pipelineOptions.showBadges, - pipelineOptions.showIcons + pipelineOptions.showIcons, + '', + false ); useEffect(() => { diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx index ffd7282b..f84e3325 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx @@ -3,7 +3,6 @@ import { ComponentFactory, ModelKind, SpacerNode, - DefaultTaskGroup, DEFAULT_TASK_NODE_TYPE, DEFAULT_SPACER_NODE_TYPE, DEFAULT_EDGE_TYPE, @@ -51,13 +50,13 @@ const pipelineComponentFactory: ComponentFactory = ( } switch (type) { case DEFAULT_TASK_NODE_TYPE: - return withContextMenu(() => defaultMenu)(withSelection()(DemoTaskNode)); + return withContextMenu(() => defaultMenu)(withSelection({ raiseOnSelect: false })(DemoTaskNode)); case DEFAULT_FINALLY_NODE_TYPE: - return withContextMenu(() => defaultMenu)(withSelection()(DemoFinallyNode)); + return withContextMenu(() => defaultMenu)(withSelection({ raiseOnSelect: false })(DemoFinallyNode)); case 'task-group': - return withSelection()(DemoPipelinesGroup); + return withSelection({ raiseOnSelect: false })(DemoPipelinesGroup); case 'finally-group': - return DefaultTaskGroup; + return withSelection({ raiseOnSelect: false })(DemoPipelinesGroup); case DEFAULT_SPACER_NODE_TYPE: return SpacerNode; case SPACER_EDGE_TYPE: diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx index 15a3d28c..105c714a 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx @@ -106,9 +106,8 @@ export const useDemoPipelineNodes = ( i++; } + const parallelTasks: PipelineNodeModel[] = []; if (layout) { - const parallelTasks: PipelineNodeModel[] = []; - for (let i = 0; i < PARALLEL_TASKS_COUNT; i++) { const parallelTask: PipelineNodeModel = { id: `parallelTasks-${i}`, @@ -144,19 +143,6 @@ export const useDemoPipelineNodes = ( } } tasks.push(...parallelTasks); - - if (showGroups) { - tasks.push({ - id: `group-parallels`, - type: 'task-group', - children: parallelTasks.map((t) => t.id), - group: true, - label: 'Parallel tasks', - data: { - badge: 'Label' - } - }); - } } const finallyNodes = []; @@ -167,7 +153,9 @@ export const useDemoPipelineNodes = ( label: `Finally task ${i}`, width: FINALLY_TASK_WIDTH, height: DEFAULT_TASK_HEIGHT, - style: { paddingLeft: DEFAULT_WHEN_SIZE + DEFAULT_WHEN_OFFSET } + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + } }; if (!layout) { @@ -181,6 +169,7 @@ export const useDemoPipelineNodes = ( const finallyGroup = { id: 'finally-group', type: 'finally-group', + label: 'Finally group', children: finallyNodes.map((n) => n.id), group: true }; @@ -206,6 +195,19 @@ export const useDemoPipelineNodes = ( }, [] as PipelineNodeModel[]); tasks.push(...taskGroups); + + if (parallelTasks.length > 0) { + tasks.push({ + id: `group-parallels`, + type: 'task-group', + children: parallelTasks.map((t) => t.id), + group: true, + label: 'Parallel tasks', + data: { + badge: 'Label' + } + }); + } } const iconTask1: PipelineNodeModel = { @@ -232,7 +234,7 @@ export const useDemoPipelineNodes = ( }; if (!layout) { - const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW) - 1; + const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW); const columnWidth = COLUMN_WIDTH + (showIcons ? 15 : 0) + (showBadges ? 32 : 0) + (showContextMenu ? 20 : 0); iconTask1.x = (showIcons ? 28 : 0) + columnWidth; iconTask1.y = GRAPH_MARGIN_TOP + row * ROW_HEIGHT; @@ -261,7 +263,7 @@ export const useDemoPipelineNodes = ( }; if (!layout) { - const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW) - 1; + const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW); const columnWidth = COLUMN_WIDTH + (showIcons ? 15 : 0) + (showBadges ? 32 : 0) + (showContextMenu ? 20 : 0); iconTask2.x = (showIcons ? 28 : 0) + 2 * columnWidth; iconTask2.y = GRAPH_MARGIN_TOP + row * ROW_HEIGHT; @@ -290,7 +292,7 @@ export const useDemoPipelineNodes = ( }; if (!layout) { - const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW) - 1; + const row = Math.ceil((TASK_STATUSES.length + 1) / STATUS_PER_ROW); const columnWidth = COLUMN_WIDTH + (showIcons ? 15 : 0) + (showBadges ? 32 : 0) + (showContextMenu ? 20 : 0); iconTask3.x = (showIcons ? 28 : 0) + 3 * columnWidth; iconTask3.y = GRAPH_MARGIN_TOP + row * ROW_HEIGHT; diff --git a/packages/module/patternfly-docs/content/examples/Accessibility.md b/packages/module/patternfly-docs/content/examples/Accessibility.md new file mode 100644 index 00000000..b4afed43 --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/Accessibility.md @@ -0,0 +1,15 @@ +--- +id: Accessibility +section: extensions +subsection: topology +sortValue: 999 +--- + +## Pipelines layout + +By default, `` and `` are included in the keyboard tab order (`tabIndex` is `0`). Tab order follows the order of items in the `model`. An expanded group receives focus before the nodes inside it. To set a custom tab order, pass `tabIndex` on `` or ``. + +When you select nodes and groups with `withSelection` or `useSelection`, `raiseOnSelect` defaults to `true`. That option moves the selected item to the end of its siblings, which also moves it in the tab order. Each later selection places that item after the one you selected before it. To keep tab order stable, pass `{ raiseOnSelect: false }` to `withSelection` or `useSelection`. + +To stop screen readers from announcing truncated labels twice, set `labelTooltipTrigger` to `mouseenter` on ``. The tooltip then opens on hover only, so the truncated label is not added to the keyboard tab order. + diff --git a/packages/module/src/components/VisualizationSurface.tsx b/packages/module/src/components/VisualizationSurface.tsx index 22ee595d..9956ec08 100644 --- a/packages/module/src/components/VisualizationSurface.tsx +++ b/packages/module/src/components/VisualizationSurface.tsx @@ -6,11 +6,12 @@ import { observer } from 'mobx-react'; import { debounce, getResizeObserver } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; import styles from '../css/topology-components'; -import { State } from '../types'; +import { isNode, Node, State } from '../types'; import SVGDefsProvider from './defs/SVGDefsProvider'; import ElementWrapper from './ElementWrapper'; import Dimensions from '../geom/Dimensions'; import useVisualizationController from '../hooks/useVisualizationController'; +import { findParentElement } from '../pipelines'; interface VisualizationSurfaceProps { /** State to be passed to the controller */ @@ -73,7 +74,21 @@ const VisualizationSurface: FunctionComponent = ({ st const graph = controller.getGraph(); return ( -
+
{ + const parentElement = findParentElement(controller, e.target); + if (parentElement) { + if (isNode(parentElement)) { + controller + .getGraph() + .panIntoView(parentElement as Node, { minimumVisible: parentElement.getDimensions().width / 2 }); + } + } + }} + > diff --git a/packages/module/src/components/layers/LayersProvider.tsx b/packages/module/src/components/layers/LayersProvider.tsx index 0612f156..f2ef3ce7 100644 --- a/packages/module/src/components/layers/LayersProvider.tsx +++ b/packages/module/src/components/layers/LayersProvider.tsx @@ -47,7 +47,7 @@ export default class LayersProvider extends Component {layerIds.map((id) => ( - this.setDomLayers(r, id)}> + this.setDomLayers(r, id)} tabIndex={-1}> {id === DEFAULT_LAYER && this.state[id] ? children : undefined} ))} diff --git a/packages/module/src/components/nodes/labels/LabelActionIcon.tsx b/packages/module/src/components/nodes/labels/LabelActionIcon.tsx index 6da0bf39..b47ec1f6 100644 --- a/packages/module/src/components/nodes/labels/LabelActionIcon.tsx +++ b/packages/module/src/components/nodes/labels/LabelActionIcon.tsx @@ -1,11 +1,14 @@ -import { forwardRef } from 'react'; +import { forwardRef, useRef } from 'react'; import { useSize } from '../../../utils'; import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-components'; +import { handleKeyboardSelection } from '../../../utils/accessibility-utils'; interface LabelActionIconProps { className?: string; icon: React.ReactElement; + 'aria-label'?: string; + tabIndex?: number; onClick: (e: React.MouseEvent) => void; iconOffsetX?: number; iconOffsetY?: number; @@ -17,8 +20,24 @@ interface LabelActionIconProps { } const LabelActionIcon = forwardRef( - ({ icon, onClick, className, x, y, paddingX, height, iconOffsetX = 0, iconOffsetY = 0 }, actionRef) => { + ( + { + icon, + onClick, + 'aria-label': ariaLabel, + tabIndex = 0, + className, + x, + y, + paddingX, + height, + iconOffsetX = 0, + iconOffsetY = 0 + }, + actionRef + ) => { const [iconSize, iconRef] = useSize([icon, paddingX]); + const clickRef = useRef(null); const iconWidth = iconSize?.width ?? 0; const iconHeight = iconSize?.height ?? 0; const iconY = (height - iconHeight) / 2; @@ -33,7 +52,7 @@ const LabelActionIcon = forwardRef( }; return ( - + {iconSize && ( ( y={y} width={iconWidth + paddingX * 2} height={height} + tabIndex={ariaLabel ? tabIndex : undefined} + aria-label={ariaLabel} + onKeyDown={handleKeyboardSelection(clickRef)} + data-id="context-icon" /> )} void; + actionIconAriaLabel?: string; + actionIconTabIndex?: number; badge?: string; badgeColor?: string; badgeTextColor?: string; @@ -82,6 +84,8 @@ const NodeLabel: React.FunctionComponent = ({ actionIcon, actionIconClassName, onActionIconClick, + actionIconAriaLabel, + actionIconTabIndex, boxRef, ...other }) => { @@ -293,6 +297,8 @@ const NodeLabel: React.FunctionComponent = ({ icon={actionIcon} className={actionIconClassName} onClick={onActionIconClick} + aria-label={actionIconAriaLabel} + tabIndex={actionIconTabIndex} /> )} diff --git a/packages/module/src/css/topology-components.css b/packages/module/src/css/topology-components.css index fee61d6d..22f2a048 100644 --- a/packages/module/src/css/topology-components.css +++ b/packages/module/src/css/topology-components.css @@ -1,6 +1,11 @@ :root { --pf-topology-visualization-surface--BackgroundColor: transparent; + --pf-topology__focus--outline-color: var(--pf-t--global--focus-ring--color--default); + --pf-topology__focus--outline-style: solid; + --pf-topology__focus--outline-width: var(--pf-t--global--border--width--strong); + --pf-topology__focus--outline-offset: var(--pf-t--global--focus-ring--position--offset); + /* Create connector */ /* Remove --pf-topology-create-connector-color at a breaking change */ --pf-topology-create-connector-color: var(--pf-t--global--border--color--on-secondary); @@ -578,6 +583,13 @@ stroke-width: var(--pf-topology__group__background--StrokeWidth); } +.pf-topology__group__background:focus-visible { + outline-color: var(--pf-topology__focus--outline-color); + outline-style: var(--pf-topology__focus--outline-style); + outline-width: var(--pf-topology__focus--outline-width); + outline-offset: var(--pf-topology__focus--outline-offset); +} + .pf-topology__group.pf-m-alt-group .pf-topology__group__background { --pf-topology__group__background--Fill: var(--pf-topology__group--m-alt-group--topology__group__background--Fill); --pf-topology__group__background--Stroke: var(--pf-topology__group--m-alt-group--topology__group__background--Stroke); diff --git a/packages/module/src/css/topology-pipelines.css b/packages/module/src/css/topology-pipelines.css index 13fdfea1..16c44f3e 100644 --- a/packages/module/src/css/topology-pipelines.css +++ b/packages/module/src/css/topology-pipelines.css @@ -1,4 +1,8 @@ :root { + --pf-topology-pipelines__pill--focus--outline-color: var(--pf-t--global--focus-ring--color--default); + --pf-topology-pipelines__pill--focus--outline-style: solid; + --pf-topology-pipelines__pill--focus--outline-width: var(--pf-t--global--border--width--strong); + --pf-topology-pipelines__pill--focus--outline-offset: var(--pf-t--global--focus-ring--position--offset); /* pill */ --pf-topology-pipelines__pill--Color: var(--pf-t--global--text--color--regular); @@ -126,6 +130,12 @@ stroke-width: var(--pf-topology-pipelines__pill-background--StrokeWidth); stroke: var(--pf-topology-pipelines__pill-background--Stroke); } +.pf-topology-pipelines__pill-background:focus-visible { + outline-color: var(--pf-topology-pipelines__pill--focus--outline-color); + outline-style: var(--pf-topology-pipelines__pill--focus--outline-style); + outline-width: var(--pf-topology-pipelines__pill--focus--outline-width); + outline-offset: var(--pf-topology-pipelines__pill--focus--outline-offset); +} .pf-topology-pipelines__pill.pf-m-danger .pf-topology-pipelines__pill-background { --pf-topology-pipelines__pill-background--Stroke: var(--pf-topology-pipelines__pill-background--m-danger--Fill); @@ -471,6 +481,13 @@ stroke: var(--pf-topology-pipelines__pill-background--Stroke); } +.pf-topology-pipelines__status-icon-background:focus-visible { + outline-color: var(--pf-topology-pipelines__pill--focus--outline-color); + outline-style: var(--pf-topology-pipelines__pill--focus--outline-style); + outline-width: var(--pf-topology-pipelines__pill--focus--outline-width); + outline-offset: var(--pf-topology-pipelines__pill--focus--outline-offset); +} + .pf-topology-pipelines__status-icon-background.pf-m-danger { --pf-topology-pipelines__pill-background--Stroke: var(--pf-topology-pipelines__pill-background--m-danger--Stroke); } diff --git a/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx b/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx index 3754c0e1..b9a1e8c3 100644 --- a/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx +++ b/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx @@ -26,6 +26,10 @@ export interface DefaultTaskGroupProps { className?: string; /** The graph group node element to represent */ element: GraphElement; + /** Adds accessible text to the node. Defaults to node label followed by status */ + 'aria-label'?: string; + /** tab index for the node to help improve tab order */ + tabIndex?: number; /** Flag if the node accepts drop operations */ droppable?: boolean; /** Flag if the current drag operation can be dropped on the node */ @@ -88,6 +92,10 @@ export interface DefaultTaskGroupProps { badgeLocation?: BadgeLocation; /** Flag if the group is collapsible */ collapsible?: boolean; + /** Aria label for collapse button when shown. Defaults to 'Collapse' **/ + collapseAriaLabel?: string; + /** Aria label for expand button when shown. Defaults to 'Expand' **/ + expandAriaLabel?: string; /** Width of the collapsed group */ collapsedWidth?: number; /** Height of the collapsed group */ diff --git a/packages/module/src/pipelines/components/groups/DefaultTaskGroupCollapsed.tsx b/packages/module/src/pipelines/components/groups/DefaultTaskGroupCollapsed.tsx index b4c5d44f..992e12ca 100644 --- a/packages/module/src/pipelines/components/groups/DefaultTaskGroupCollapsed.tsx +++ b/packages/module/src/pipelines/components/groups/DefaultTaskGroupCollapsed.tsx @@ -16,6 +16,7 @@ const DefaultTaskGroupCollapsed: React.FunctionComponent { return ( @@ -23,6 +24,7 @@ const DefaultTaskGroupCollapsed: React.FunctionComponent : undefined} onActionIconClick={() => onCollapseChange(element, false)} + actionIconAriaLabel={actionIconAriaLabel} shadowCount={shadowCount} {...rest} /> diff --git a/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx b/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx index 860f294e..d1dffee4 100644 --- a/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx +++ b/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { observer } from 'mobx-react'; import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-components'; @@ -13,13 +13,17 @@ import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; import TaskGroupSourceAnchor from '../anchors/TaskGroupSourceAnchor'; import TaskGroupTargetAnchor from '../anchors/TaskGroupTargetAnchor'; import { DefaultTaskGroupProps } from './DefaultTaskGroup'; +import { generateTaskAriaLabel, handleKeyboardSelection, onSelectAndReFocus } from '../../../utils/accessibility-utils'; const DefaultTaskGroupExpanded: React.FunctionComponent & { element: Node }> = observer( ({ className, element, + 'aria-label': ariaLabel, + tabIndex = 0, collapsible, + collapseAriaLabel = 'Collapse', selected, onSelect, hover, @@ -62,6 +66,8 @@ const DefaultTaskGroupExpanded: React.FunctionComponent : undefined} + actionIconAriaLabel={collapseAriaLabel} + actionIconTabIndex={tabIndex} onActionIconClick={() => onCollapseChange(element, true)} > {label || element.getLabel()} @@ -207,9 +215,20 @@ const DefaultTaskGroupExpanded: React.FunctionComponent + { + onSelectAndReFocus(onSelect, e, focusableId); + } + : undefined + } + ref={clickRef} + className={groupClassName} + > - + {groupLabel && isHover ? ( diff --git a/packages/module/src/pipelines/components/nodes/TaskNode.tsx b/packages/module/src/pipelines/components/nodes/TaskNode.tsx index e8a23043..2e479300 100644 --- a/packages/module/src/pipelines/components/nodes/TaskNode.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskNode.tsx @@ -24,6 +24,10 @@ export interface TaskNodeProps { className?: string; /** The graph node element to represent */ element: GraphElement; + /** Adds accessible text to the node. Defaults to node label followed by status */ + 'aria-label'?: string; + /** tab index for the node to help improve tab order */ + tabIndex?: number; /** Padding to use before and after contents */ paddingX?: number; /** Padding to use above and below contents */ @@ -72,6 +76,8 @@ export interface TaskNodeProps { hover?: boolean; /** The maximum length of the label before truncation */ truncateLength?: number; + /** Trigger mechanism for the label tooltip when truncated **/ + labelTooltipTrigger?: 'click' | 'mouseenter' | 'focus'; /** Flag if the tooltip is disabled */ disableTooltip?: boolean; /** Tooltip to show on node hover */ @@ -84,6 +90,8 @@ export interface TaskNodeProps { whenOffset?: number; /** Icon to use for the action menu */ actionIcon?: React.ReactElement; + /** Aria label for action icon when shown. **/ + actionIconAriaLabel?: string; /** Additional classes to use for the action icon */ actionIconClassName?: string; /** Callback when the action icon is clicked */ diff --git a/packages/module/src/pipelines/components/nodes/TaskPill.tsx b/packages/module/src/pipelines/components/nodes/TaskPill.tsx index f0f04aba..c1609963 100644 --- a/packages/module/src/pipelines/components/nodes/TaskPill.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskPill.tsx @@ -1,4 +1,5 @@ import { useRef, useMemo, useCallback } from 'react'; +import type { FC, Ref, ReactNode } from 'react'; import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-pipelines'; import topologyStyles from '../../../css/topology-components'; @@ -20,6 +21,7 @@ import LabelBadge from '../../../components/nodes/labels/LabelBadge'; import LabelIcon from '../../../components/nodes/labels/LabelIcon'; import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; import { TaskNodeProps } from './TaskNode'; +import { generateTaskAriaLabel, handleKeyboardSelection, onSelectAndReFocus } from '../../../utils/accessibility-utils'; const STATUS_ICON_SIZE = 16; @@ -28,14 +30,16 @@ export interface TaskPillProps extends Omit { width?: number; x: number; y: number; - taskRef?: React.Ref; + taskRef?: Ref; pillRef: (node: SVGGraphicsElement) => void; element: Node; } -const TaskPill: React.FC = observer( +const TaskPill: FC = observer( ({ element, + 'aria-label': ariaLabel = null, + tabIndex = 0, taskRef, pillRef, className, @@ -64,6 +68,7 @@ const TaskPill: React.FC = observer( taskIconPadding = 4, hover, truncateLength = 14, + labelTooltipTrigger, disableTooltip = false, selected, onSelect, @@ -72,6 +77,7 @@ const TaskPill: React.FC = observer( hideContextMenuKebab, actionIcon, actionIconClassName, + actionIconAriaLabel, onActionIconClick, shadowCount = 0, shadowOffset = 8, @@ -94,6 +100,8 @@ const TaskPill: React.FC = observer( const [contextSize, contextRef] = useSize([onContextMenu, paddingX]); const detailsLevel = element.getGraph().getDetailsLevel(); const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; + const focusableId = `${element.getId()}-node-focusable`; + const clickRef = useRef(null); const textWidth = textSize?.width ?? 0; const textHeight = textSize?.height ?? 0; @@ -260,7 +268,7 @@ const TaskPill: React.FC = observer( /> ) : null; - let badgeComponent: React.ReactNode; + let badgeComponent: ReactNode; if (badgeLabel && badgeTooltip) { badgeComponent = ( @@ -295,9 +303,22 @@ const TaskPill: React.FC = observer( runStatusModifier, selected && 'pf-m-selected' )} + id={focusableId} cx={statusBackgroundRadius} cy={statusBackgroundRadius} r={statusBackgroundRadius} + tabIndex={tabIndex} + aria-label={ariaLabel || generateTaskAriaLabel(element.getLabel(), status)} + style={{ borderRadius: statusBackgroundRadius }} + onKeyDown={handleKeyboardSelection(clickRef)} + ref={clickRef} + onClick={ + onSelect + ? (e) => { + onSelectAndReFocus(onSelect, e, focusableId); + } + : undefined + } /> {status && (!hiddenDetailsShownStatuses || hiddenDetailsShownStatuses.includes(status)) ? ( @@ -336,7 +357,13 @@ const TaskPill: React.FC = observer( { + onSelectAndReFocus(onSelect, e, focusableId); + } + : undefined + } onContextMenu={onContextMenu} ref={taskRef} > @@ -351,10 +378,22 @@ const TaskPill: React.FC = observer( rx={height / 2} className={css(styles.topologyPipelinesPillBackground)} filter={filter} + style={{ borderRadius: height / 2 }} + id={focusableId} + tabIndex={tabIndex} + aria-label={ariaLabel || generateTaskAriaLabel(element.getLabel(), status)} + onClick={ + onSelect + ? (e) => { + onSelectAndReFocus(onSelect, e, focusableId); + } + : undefined + } + onKeyDown={handleKeyboardSelection()} /> {element.getLabel() !== label && !disableTooltip ? ( - + {nameLabel} ) : ( @@ -415,6 +454,8 @@ const TaskPill: React.FC = observer( icon={actionIcon} className={actionIconClassName} onClick={onActionIconClick} + aria-label={actionIconAriaLabel} + tabIndex={tabIndex} /> )} diff --git a/packages/module/src/pipelines/utils/utils.ts b/packages/module/src/pipelines/utils/utils.ts index 218a02f5..925bcc05 100644 --- a/packages/module/src/pipelines/utils/utils.ts +++ b/packages/module/src/pipelines/utils/utils.ts @@ -1,7 +1,8 @@ import styles from '../../css/topology-pipelines'; import { PipelineNodeModel, RunStatus, WhenStatus } from '../types'; -import { EdgeModel } from '../../types'; +import { Controller, EdgeModel, GraphElement } from '../../types'; import { DEFAULT_EDGE_TYPE, DEFAULT_FINALLY_NODE_TYPE, DEFAULT_SPACER_NODE_TYPE } from '../const'; +import { ATTR_DATA_ID, ATTR_DATA_KIND } from '../../const'; export const nonShadowModifiers: string[] = [ styles.modifiers.danger, @@ -249,3 +250,15 @@ export const getEdgesFromNodes = ( return edges; }; + +export const findParentElement = (controller: Controller, element?: Element): GraphElement | null => { + if (!element) { + return null; + } + + if (element.getAttribute(ATTR_DATA_ID) && element.getAttribute(ATTR_DATA_KIND)) { + return controller.getElementById(element.getAttribute(ATTR_DATA_ID)); + } + + return findParentElement(controller, element.parentElement); +}; diff --git a/packages/module/src/utils/accessibility-utils.ts b/packages/module/src/utils/accessibility-utils.ts new file mode 100644 index 00000000..83669c5e --- /dev/null +++ b/packages/module/src/utils/accessibility-utils.ts @@ -0,0 +1,66 @@ +import type { OnSelect } from '../behavior'; +import { RunStatus } from '../pipelines'; +import { action } from '../mobx-exports'; +import { MutableRefObject } from 'react'; + +export const onSelectAndReFocus = (onSelect: OnSelect | undefined, e: React.MouseEvent, focusId: string): void => { + if (onSelect) { + onSelect(e); + requestAnimationFrame(() => { + const element = document.getElementById(focusId); + if (element) { + element.focus(); + } + }); + } +}; + +export const handleKeyboardSelection = ( + clickTarget?: MutableRefObject | MutableRefObject | MutableRefObject +) => + action((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + const mouseEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + + if (clickTarget?.current) { + clickTarget.current.dispatchEvent(mouseEvent); + } else { + e.currentTarget.dispatchEvent(mouseEvent); + } + } + }); + +export const runStatusText = (status?: RunStatus) => { + switch (status) { + case RunStatus.Succeeded: + return 'succeeded'; + case RunStatus.Failed: + return 'failed'; + case RunStatus.Running: + return 'running'; + case RunStatus.InProgress: + return 'in progress'; + case RunStatus.FailedToStart: + return 'failed to start'; + case RunStatus.Skipped: + return 'skipped'; + case RunStatus.Cancelled: + return 'cancelled'; + case RunStatus.Pending: + return 'pending'; + case RunStatus.Idle: + return 'idle'; + default: + return ''; + } +}; + +export const generateTaskAriaLabel = (label: string, status?: RunStatus) => + status ? `${label} (${runStatusText(status)})` : label;