1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Accessibility: Improve keyboard navigation in the Markdown and note toolbar (#10819)

This commit is contained in:
Henry Heino 2024-08-03 08:42:46 -07:00 committed by GitHub
parent 19af6a8722
commit 9cf298ef44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 336 additions and 124 deletions

View File

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { AppState } from '../../../../app.reducer'; import { AppState } from '../../../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '../../../../services/commands/stateToWhenClauseContext'; import stateToWhenClauseContext from '../../../../services/commands/stateToWhenClauseContext';
import { _ } from '@joplin/lib/locale';
const { buildStyle } = require('@joplin/lib/theme'); const { buildStyle } = require('@joplin/lib/theme');
interface ToolbarProps { interface ToolbarProps {
@ -29,7 +30,14 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
function Toolbar(props: ToolbarProps) { function Toolbar(props: ToolbarProps) {
const styles = styles_(props); 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) => { const mapStateToProps = (state: AppState) => {

View File

@ -6,6 +6,7 @@ import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/comm
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import { buildStyle } from '@joplin/lib/theme'; import { buildStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
interface NoteToolbarProps { interface NoteToolbarProps {
themeId: number; themeId: number;
@ -29,7 +30,14 @@ function styles_(props: NoteToolbarProps) {
function NoteToolbar(props: NoteToolbarProps) { function NoteToolbar(props: NoteToolbarProps) {
const styles = styles_(props); 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()); const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());

View File

@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles_ from './styles'; import styles_ from './styles';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _ } from '@joplin/lib/locale';
export enum Value { export enum Value {
Markdown = 'markdown', Markdown = 'markdown',
@ -11,6 +12,8 @@ export interface Props {
themeId: number; themeId: number;
value: Value; value: Value;
toolbarButtonInfo: ToolbarButtonInfo; toolbarButtonInfo: ToolbarButtonInfo;
tabIndex?: number;
buttonRef?: React.Ref<HTMLButtonElement>;
} }
export default function ToggleEditorsButton(props: Props) { export default function ToggleEditorsButton(props: Props) {
@ -18,14 +21,17 @@ export default function ToggleEditorsButton(props: Props) {
return ( return (
<button <button
ref={props.buttonRef}
style={style.button} style={style.button}
disabled={!props.toolbarButtonInfo.enabled} disabled={!props.toolbarButtonInfo.enabled}
aria-label={props.toolbarButtonInfo.tooltip} aria-label={props.toolbarButtonInfo.tooltip}
aria-description={_('Switch to the %s Editor', props.value !== Value.Markdown ? _('Markdown') : _('Rich Text'))}
title={props.toolbarButtonInfo.tooltip} title={props.toolbarButtonInfo.tooltip}
type="button" type="button"
className={`tox-tbtn ${props.value}-active`} className={`tox-tbtn ${props.value}-active`}
aria-pressed="false" aria-pressed="false"
onClick={props.toolbarButtonInfo.onClick} onClick={props.toolbarButtonInfo.onClick}
tabIndex={props.tabIndex}
> >
<div style={style.leftInnerButton}> <div style={style.leftInnerButton}>
<i style={style.leftIcon} className="fab fa-markdown"></i> <i style={style.leftIcon} className="fab fa-markdown"></i>

View File

@ -2,98 +2,207 @@ import * as React from 'react';
import ToolbarButton from './ToolbarButton/ToolbarButton'; import ToolbarButton from './ToolbarButton/ToolbarButton';
import ToggleEditorsButton, { Value } from './ToggleEditorsButton/ToggleEditorsButton'; import ToggleEditorsButton, { Value } from './ToggleEditorsButton/ToggleEditorsButton';
import ToolbarSpace from './ToolbarSpace'; import ToolbarSpace from './ToolbarSpace';
const { connect } = require('react-redux'); import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
const { themeStyle } = require('@joplin/lib/theme'); 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 { interface Props {
themeId: number; themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied style: React.CSSProperties;
style: any; items: ToolbarItemInfo[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
items: any[];
disabled: boolean; disabled: boolean;
'aria-label': string;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const getItemType = (item: ToolbarItemInfo) => {
class ToolbarBaseComponent extends React.Component<Props, any> { return item.type ?? 'button';
};
public render() { const isFocusable = (item: ToolbarItemInfo) => {
const theme = themeStyle(this.props.themeId); if (!item.enabled) {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied return getItemType(item) === 'button';
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 useCategorizedItems = (items: ToolbarItemInfo[]) => {
const groupStyle: any = { return useMemo(() => {
display: 'flex', const itemsLeft: ToolbarItemInfo[] = [];
flexDirection: 'row', const itemsCenter: ToolbarItemInfo[] = [];
boxSizing: 'border-box', const itemsRight: ToolbarItemInfo[] = [];
minWidth: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied if (items) {
const leftItemComps: any[] = []; for (const item of items) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const type = getItemType(item);
const centerItemComps: any[] = []; if (item.name === 'toggleEditors') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied itemsRight.push(item);
const rightItemComps: any[] = []; } else if (type === 'button') {
const target = ['historyForward', 'historyBackward', 'toggleExternalEditing'].includes(item.name) ? itemsLeft : itemsCenter;
if (this.props.items) { target.push(item);
for (let i = 0; i < this.props.items.length; i++) { } else if (type === 'separator') {
const o = this.props.items[i]; itemsCenter.push(item);
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} />);
} }
} }
} }
return ( return {
<div className="editor-toolbar" style={style}> itemsLeft,
<div style={groupStyle}> itemsCenter,
{leftItemComps} itemsRight,
</div> allItems: itemsLeft.concat(itemsCenter, itemsRight),
<div style={groupStyle}> };
{centerItemComps} }, [items]);
</div> };
<div style={{ ...groupStyle, flex: 1, justifyContent: 'flex-end' }}>
{rightItemComps}
</div>
</div>
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const useKeyboardHandler = (
const mapStateToProps = (state: any) => { 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;
}
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 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>
);
};
const mapStateToProps = (state: AppState) => {
return { themeId: state.settings.theme }; return { themeId: state.settings.theme };
}; };

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { StyledRoot, StyledIconSpan, StyledIconI } from './styles'; import { StyledIconSpan, StyledIconI } from './styles';
interface Props { interface Props {
readonly themeId: number; readonly themeId: number;
@ -10,6 +10,9 @@ interface Props {
readonly iconName?: string; readonly iconName?: string;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly backgroundHover?: boolean; readonly backgroundHover?: boolean;
readonly tabIndex?: number;
buttonRef?: React.Ref<HTMLButtonElement>;
} }
function isFontAwesomeIcon(iconName: string) { function isFontAwesomeIcon(iconName: string) {
@ -34,7 +37,7 @@ export default function ToolbarButton(props: Props) {
const iconName = getProp(props, 'iconName'); const iconName = getProp(props, 'iconName');
if (iconName) { if (iconName) {
const IconClass = isFontAwesomeIcon(iconName) ? StyledIconI : StyledIconSpan; const IconClass = isFontAwesomeIcon(iconName) ? StyledIconI : StyledIconSpan;
icon = <IconClass className={iconName} title={title}/>; icon = <IconClass className={iconName} aria-label='' hasTitle={!!title} role='img'/>;
} }
// Keep this for legacy compatibility but for consistency we should use "disabled" prop // Keep this for legacy compatibility but for consistency we should use "disabled" prop
@ -42,8 +45,9 @@ export default function ToolbarButton(props: Props) {
if (isEnabled === null) isEnabled = true; if (isEnabled === null) isEnabled = true;
if (props.disabled) isEnabled = false; if (props.disabled) isEnabled = false;
const classes = ['button']; const classes = ['button', 'toolbar-button'];
if (!isEnabled) classes.push('disabled'); if (!isEnabled) classes.push('disabled');
if (title) classes.push('-has-title');
const onClick = getProp(props, 'onClick'); const onClick = getProp(props, 'onClick');
const style: React.CSSProperties = { const style: React.CSSProperties = {
@ -51,25 +55,27 @@ export default function ToolbarButton(props: Props) {
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis' }; textOverflow: 'ellipsis' };
const disabled = !isEnabled; const disabled = !isEnabled;
return ( return (
<StyledRoot <button
className={classes.join(' ')} className={classes.join(' ')}
title={tooltip} title={tooltip}
href="#"
hasTitle={!!title}
onClick={() => { onClick={() => {
if (isEnabled && onClick) onClick(); if (isEnabled && onClick) onClick();
}} }}
ref={props.buttonRef}
// At least on MacOS, the disabled HTML prop isn't sufficient for the screen reader // At least on MacOS, the disabled HTML prop isn't sufficient for the screen reader
// to read the element as disable. For this, aria-disabled is necessary. // to read the element as disable. For this, aria-disabled is necessary.
disabled={disabled} disabled={disabled}
aria-label={!title ? tooltip : undefined}
aria-description={title ? tooltip : undefined}
aria-disabled={!isEnabled} aria-disabled={!isEnabled}
role='button' tabIndex={props.tabIndex}
> >
{icon} {icon}
<span style={style}>{title}</span> <span style={style}>{title}</span>
</StyledRoot> </button>
); );
} }

View File

@ -3,46 +3,15 @@ import { ThemeStyle } from '@joplin/lib/theme';
const styled = require('styled-components').default; const styled = require('styled-components').default;
const { css } = require('styled-components'); const { css } = require('styled-components');
interface RootProps {
readonly theme: ThemeStyle;
readonly disabled: boolean;
readonly hasTitle: boolean;
}
export const StyledRoot = styled.a<RootProps>`
opacity: ${(props: RootProps) => props.disabled ? 0.3 : 1};
height: ${(props: RootProps) => props.theme.toolbarHeight}px;
min-height: ${(props: RootProps) => props.theme.toolbarHeight}px;
width: ${(props: RootProps) => props.hasTitle ? 'auto' : `${props.theme.toolbarHeight}px`};
max-width: ${(props: RootProps) => props.hasTitle ? 'auto' : `${props.theme.toolbarHeight}px`};
display: flex;
align-items: center;
justify-content: center;
cursor: default;
border-radius: 3px;
box-sizing: border-box;
color: ${(props: RootProps) => props.theme.color3};
font-size: ${(props: RootProps) => props.theme.toolbarIconSize * 0.8}px;
padding-left: 5px;
padding-right: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: ${(props: RootProps) => props.disabled ? 'none' : props.theme.backgroundColorHover3};
}
`;
interface IconProps { interface IconProps {
readonly theme: ThemeStyle; readonly theme: ThemeStyle;
readonly title: string; readonly hasTitle: boolean;
} }
const iconStyle = css<IconProps>` const iconStyle = css<IconProps>`
font-size: ${(props: IconProps) => props.theme.toolbarIconSize}px; font-size: ${(props: IconProps) => props.theme.toolbarIconSize}px;
color: ${(props: IconProps) => props.theme.color3}; color: ${(props: IconProps) => props.theme.color3};
margin-right: ${(props: IconProps) => props.title ? 5 : 0}px; margin-right: ${(props: IconProps) => props.hasTitle ? 5 : 0}px;
pointer-events: none; /* Need this to get button tooltip to work */ pointer-events: none; /* Need this to get button tooltip to work */
`; `;

View File

@ -0,0 +1,21 @@
.editor-toolbar {
display: flex;
flex-direction: row;
box-sizing: border-box;
background-color: var(--joplin-background-color3);
padding: var(--joplin-toolbar-padding);
padding-right: var(--joplin-main-padding);
> .group {
display: flex;
flex-direction: row;
box-sizing: border-box;
min-width: 0;
&.-right {
flex: 1;
justify-content: flex-end;
}
}
}

View File

@ -1,4 +1,6 @@
@use './dialog-modal-layer.scss'; @use './dialog-modal-layer.scss';
@use './user-webview-dialog.scss'; @use './user-webview-dialog.scss';
@use './prompt-dialog.scss'; @use './prompt-dialog.scss';
@use './toolbar-button.scss';
@use './editor-toolbar.scss';

View File

@ -0,0 +1,42 @@
.toolbar-button {
border: none;
background: transparent;
color: inherit;
padding: 0;
opacity: 1;
height: var(--joplin-toolbar-height);
min-height: var(--joplin-toolbar-height);
width: var(--joplin-toolbar-height);
max-width: var(--joplin-toolbar-height);
&.-has-title {
width: auto;
max-width: unset;
}
&:disabled {
opacity: 0.3;
}
display: flex;
align-items: center;
justify-content: center;
cursor: default;
border-radius: 3px;
box-sizing: border-box;
color: var(--joplin-color3);
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
padding-left: 5px;
padding-right: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:not(:disabled) {
&:hover, &:focus-visible {
background-color: var(--joplin-background-color-hover3);
}
}
}

View File

@ -26,5 +26,42 @@ test.describe('markdownEditor', () => {
await expect(image).toBeAttached(); await expect(image).toBeAttached();
await expect(await getImageSourceSize(image)).toMatchObject([117, 30]); await expect(await getImageSourceSize(image)).toMatchObject([117, 30]);
}); });
test('arrow keys should navigate the toolbar', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
await mainScreen.createNewNote('Note 1');
await mainScreen.createNewNote('Note 2');
const noteEditor = mainScreen.noteEditor;
await noteEditor.focusCodeMirrorEditor();
// Escape, then Shift+Tab should focus the toolbar
await mainWindow.keyboard.press('Escape');
await mainWindow.keyboard.press('Shift+Tab');
// Should focus the first item by default, the "back" arrow (back to "Note 1")
const firstItemLocator = noteEditor.toolbarButtonLocator('Back');
await expect(firstItemLocator).toBeFocused();
// Left arrow should wrap to the end
await mainWindow.keyboard.press('ArrowLeft');
const lastItemLocator = noteEditor.toolbarButtonLocator('Toggle editors');
await expect(lastItemLocator).toBeFocused();
await mainWindow.keyboard.press('ArrowRight');
await expect(firstItemLocator).toBeFocused();
// ArrowRight should skip disabled items (Forward).
await mainWindow.keyboard.press('ArrowRight');
await expect(noteEditor.toolbarButtonLocator('Toggle external editing')).toBeFocused();
// Home/end should navigate to the first/last items
await mainWindow.keyboard.press('End');
await expect(lastItemLocator).toBeFocused();
await mainWindow.keyboard.press('Home');
await expect(firstItemLocator).toBeFocused();
});
}); });

View File

@ -14,8 +14,12 @@ export default class NoteEditorPage {
this.codeMirrorEditor = this.containerLocator.locator('.cm-editor'); this.codeMirrorEditor = this.containerLocator.locator('.cm-editor');
this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]'); this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]');
this.noteTitleInput = this.containerLocator.locator('.title-input'); this.noteTitleInput = this.containerLocator.locator('.title-input');
this.attachFileButton = this.containerLocator.locator('[title^="Attach file"]'); this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' });
this.toggleEditorsButton = this.containerLocator.locator('[title^="Toggle editors"]'); this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' });
}
public toolbarButtonLocator(title: string) {
return this.containerLocator.getByRole('button', { name: title });
} }
public getNoteViewerIframe() { public getNoteViewerIframe() {

View File

@ -8,7 +8,7 @@ test.describe('settings', () => {
await mainScreen.waitFor(); await mainScreen.waitFor();
// Sort order buttons should be visible by default // Sort order buttons should be visible by default
const sortOrderLocator = mainScreen.noteListContainer.locator('[title^="Toggle sort order"]'); const sortOrderLocator = mainScreen.noteListContainer.getByRole('button', { name: 'Toggle sort order' });
await expect(sortOrderLocator).toBeVisible(); await expect(sortOrderLocator).toBeVisible();
await mainScreen.openSettings(electronApp); await mainScreen.openSettings(electronApp);