Skip to content

Commit

Permalink
Desktop: Accessibility: Improve Markdown editor toolbar focus handling
Browse files Browse the repository at this point in the history
This pull request improves focus handling in the **Markdown editor**'s
toolbar. See laurent22#10795.
  • Loading branch information
personalizedrefrigerator committed Aug 3, 2024
1 parent 5c8be44 commit 4b606ea
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 125 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { AppState } from '../../../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '../../../../services/commands/stateToWhenClauseContext';
import { _ } from '@joplin/lib/locale';
const { buildStyle } = require('@joplin/lib/theme');

interface ToolbarProps {
Expand All @@ -29,7 +30,14 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());

function Toolbar(props: ToolbarProps) {
const styles = styles_(props);
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} disabled={!!props.disabled} />;
return (
<ToolbarBase
style={styles.root}
items={props.toolbarButtonInfos}
disabled={!!props.disabled}
aria-label={_('Editor actions')}
/>
);
}

const mapStateToProps = (state: AppState) => {
Expand Down
10 changes: 9 additions & 1 deletion packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/comm
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
const { connect } = require('react-redux');
import { buildStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';

interface NoteToolbarProps {
themeId: number;
Expand All @@ -29,7 +30,14 @@ function styles_(props: NoteToolbarProps) {

function NoteToolbar(props: NoteToolbarProps) {
const styles = styles_(props);
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} disabled={props.disabled}/>;
return (
<ToolbarBase
style={styles.root}
items={props.toolbarButtonInfos}
disabled={props.disabled}
aria-label={_('Note')}
/>
);
}

const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import styles_ from './styles';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _ } from '@joplin/lib/locale';

export enum Value {
Markdown = 'markdown',
Expand All @@ -11,21 +12,26 @@ export interface Props {
themeId: number;
value: Value;
toolbarButtonInfo: ToolbarButtonInfo;
tabIndex?: number;
buttonRef?: React.Ref<HTMLButtonElement>;
}

export default function ToggleEditorsButton(props: Props) {
const style = styles_(props);

return (
<button
ref={props.buttonRef}
style={style.button}
disabled={!props.toolbarButtonInfo.enabled}
aria-label={props.toolbarButtonInfo.tooltip}
aria-description={_('Switch to the %s Editor', props.value !== Value.Markdown ? _('Markdown') : _('Rich Text'))}
title={props.toolbarButtonInfo.tooltip}
type="button"
className={`tox-tbtn ${props.value}-active`}
aria-pressed="false"
onClick={props.toolbarButtonInfo.onClick}
tabIndex={props.tabIndex}
>
<div style={style.leftInnerButton}>
<i style={style.leftIcon} className="fab fa-markdown"></i>
Expand Down
265 changes: 187 additions & 78 deletions packages/app-desktop/gui/ToolbarBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,98 +2,207 @@ import * as React from 'react';
import ToolbarButton from './ToolbarButton/ToolbarButton';
import ToggleEditorsButton, { Value } from './ToggleEditorsButton/ToggleEditorsButton';
import ToolbarSpace from './ToolbarSpace';
const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme');
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { AppState } from '../app.reducer';
import { connect } from 'react-redux';
import { useCallback, useMemo, useRef, useState } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';

interface ToolbarItemInfo extends ToolbarButtonInfo {
type?: string;
}

interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
items: any[];
style: React.CSSProperties;
items: ToolbarItemInfo[];
disabled: boolean;
'aria-label': string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
class ToolbarBaseComponent extends React.Component<Props, any> {

public render() {
const theme = themeStyle(this.props.themeId);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const style: any = { display: 'flex',
flexDirection: 'row',
boxSizing: 'border-box',
backgroundColor: theme.backgroundColor3,
padding: theme.toolbarPadding,
paddingRight: theme.mainPadding, ...this.props.style };

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const groupStyle: any = {
display: 'flex',
flexDirection: 'row',
boxSizing: 'border-box',
minWidth: 0,
const getItemType = (item: ToolbarItemInfo) => {
return item.type ?? 'button';
};

const isFocusable = (item: ToolbarItemInfo) => {
if (!item.enabled) {
return false;
}

return getItemType(item) === 'button';
};

const useCategorizedItems = (items: ToolbarItemInfo[]) => {
return useMemo(() => {
const itemsLeft: ToolbarItemInfo[] = [];
const itemsCenter: ToolbarItemInfo[] = [];
const itemsRight: ToolbarItemInfo[] = [];

if (items) {
for (const item of items) {
const type = getItemType(item);
if (item.name === 'toggleEditors') {
itemsRight.push(item);
} else if (type === 'button') {
const target = ['historyForward', 'historyBackward', 'toggleExternalEditing'].includes(item.name) ? itemsLeft : itemsCenter;
target.push(item);
} else if (type === 'separator') {
itemsCenter.push(item);
}
}
}

return {
itemsLeft,
itemsCenter,
itemsRight,
allItems: itemsLeft.concat(itemsCenter, itemsRight),
};
}, [items]);
};

const useKeyboardHandler = (
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>,
focusableItems: ToolbarItemInfo[],
) => {
const onKeyDown: React.KeyboardEventHandler<HTMLElement> = useCallback(event => {
let direction = 0;
if (event.code === 'ArrowRight') {
direction = 1;
} else if (event.code === 'ArrowLeft') {
direction = -1;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const leftItemComps: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const centerItemComps: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const rightItemComps: any[] = [];

if (this.props.items) {
for (let i = 0; i < this.props.items.length; i++) {
const o = this.props.items[i];
let key = o.iconName ? o.iconName : '';
key += o.title ? o.title : '';
key += o.name ? o.name : '';
const itemType = !('type' in o) ? 'button' : o.type;

if (!key) key = `${o.type}_${i}`;

const props = {
key: key,
themeId: this.props.themeId,
disabled: this.props.disabled,
...o,
};

if (o.name === 'toggleEditors') {
rightItemComps.push(<ToggleEditorsButton
key={o.name}
value={Value.Markdown}
themeId={this.props.themeId}
toolbarButtonInfo={o}
/>);
} else if (itemType === 'button') {
const target = ['historyForward', 'historyBackward', 'toggleExternalEditing'].includes(o.name) ? leftItemComps : centerItemComps;
target.push(<ToolbarButton {...props} />);
} else if (itemType === 'separator') {
centerItemComps.push(<ToolbarSpace {...props} />);
let handled = true;
if (direction !== 0) {
setSelectedIndex(index => {
let newIndex = (index + direction) % focusableItems.length;
if (newIndex < 0) {
newIndex += focusableItems.length;
}
return newIndex;
});
} else if (event.code === 'End') {
setSelectedIndex(focusableItems.length - 1);
} else if (event.code === 'Home') {
setSelectedIndex(0);
} else {
handled = false;
}

if (handled) {
event.preventDefault();
}
}, [focusableItems, setSelectedIndex]);

return onKeyDown;
};

const ToolbarBaseComponent: React.FC<Props> = props => {
const { itemsLeft, itemsCenter, itemsRight, allItems } = useCategorizedItems(props.items);

const [selectedIndex, setSelectedIndex] = useState(0);
const focusableItems = useMemo(() => {
return allItems.filter(isFocusable);
}, [allItems]);
const containerRef = useRef<HTMLDivElement|null>(null);
const containerHasFocus = !!containerRef.current?.contains(document.activeElement);

let keyCounter = 0;
const renderItem = (o: ToolbarItemInfo, indexInFocusable: number) => {
let key = o.iconName ? o.iconName : '';
key += o.title ? o.title : '';
key += o.name ? o.name : '';
const itemType = !('type' in o) ? 'button' : o.type;

if (!key) key = `${o.type}_${keyCounter++}`;

const buttonProps = {
key,
themeId: props.themeId,
disabled: props.disabled || !o.enabled,
...o,
};

const tabIndex = indexInFocusable === (selectedIndex % focusableItems.length) ? 0 : -1;
const setButtonRefCallback = (button: HTMLButtonElement) => {
if (tabIndex === 0 && containerHasFocus) {
focus('ToolbarBase', button);
}
};

if (o.name === 'toggleEditors') {
return <ToggleEditorsButton
key={o.name}
buttonRef={setButtonRefCallback}
value={Value.Markdown}
themeId={props.themeId}
toolbarButtonInfo={o}
tabIndex={tabIndex}
/>;
} else if (itemType === 'button') {
return (
<ToolbarButton
tabIndex={tabIndex}
buttonRef={setButtonRefCallback}
{...buttonProps}
/>
);
} else if (itemType === 'separator') {
return <ToolbarSpace {...buttonProps} />;
}

return null;
};

let focusableIndex = 0;
const renderList = (items: ToolbarItemInfo[]) => {
const result: React.ReactNode[] = [];

for (const item of items) {
result.push(renderItem(item, focusableIndex));
if (isFocusable(item)) {
focusableIndex ++;
}
}

return (
<div className="editor-toolbar" style={style}>
<div style={groupStyle}>
{leftItemComps}
</div>
<div style={groupStyle}>
{centerItemComps}
</div>
<div style={{ ...groupStyle, flex: 1, justifyContent: 'flex-end' }}>
{rightItemComps}
</div>
return result;
};

const leftItemComps = renderList(itemsLeft);
const centerItemComps = renderList(itemsCenter);
const rightItemComps = renderList(itemsRight);

const onKeyDown = useKeyboardHandler(
setSelectedIndex,
focusableItems,
);

return (
<div
ref={containerRef}
className='editor-toolbar'
style={props.style}

role='toolbar'
aria-label={props['aria-label']}

onKeyDown={onKeyDown}
>
<div className='group'>
{leftItemComps}
</div>
);
}
}
<div className='group'>
{centerItemComps}
</div>
<div className='group -right'>
{rightItemComps}
</div>
</div>
);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const mapStateToProps = (state: any) => {
const mapStateToProps = (state: AppState) => {
return { themeId: state.settings.theme };
};

Expand Down
Loading

0 comments on commit 4b606ea

Please sign in to comment.