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

Desktop: Accessibility: Fix screen reader doesn't read Goto Anything search results or help button label (#10816)

This commit is contained in:
Henry Heino 2024-08-05 11:37:23 -07:00 committed by GitHub
parent 1c2c071952
commit 9f997c2fb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 113 additions and 15 deletions

View File

@ -2,6 +2,7 @@ import * as React from 'react';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import { themeStyle } from '@joplin/lib/theme'; import { themeStyle } from '@joplin/lib/theme';
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
import { _ } from '@joplin/lib/locale';
interface Props { interface Props {
tip: string; tip: string;
@ -10,6 +11,9 @@ interface Props {
themeId: number; themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any; style: any;
'aria-controls'?: string;
'aria-expanded'?: string;
} }
class HelpButtonComponent extends React.Component<Props> { class HelpButtonComponent extends React.Component<Props> {
@ -29,11 +33,21 @@ class HelpButtonComponent extends React.Component<Props> {
const helpIconStyle = { flex: 0, width: 16, height: 16, marginLeft: 10 }; const helpIconStyle = { flex: 0, width: 16, height: 16, marginLeft: 10 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const extraProps: any = {}; const extraProps: any = {};
if (this.props.tip) extraProps['data-tip'] = this.props.tip; if (this.props.tip) {
extraProps['data-tip'] = this.props.tip;
extraProps['aria-description'] = this.props.tip;
}
return ( return (
<a href="#" style={style} onClick={this.onClick} {...extraProps}> <button
<i style={helpIconStyle} className={'fa fa-question-circle'}></i> style={style}
</a> onClick={this.onClick}
className='flat-button'
aria-controls={this.props['aria-controls']}
aria-expanded={this.props['aria-expanded']}
{...extraProps}
>
<i style={helpIconStyle} className={'fa fa-question-circle'} role='img' aria-label={_('Help')}></i>
</button>
); );
} }
} }

View File

@ -10,6 +10,10 @@ interface Props<ItemType> {
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element; itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
className?: string; className?: string;
onItemDrop?: DragEventHandler<HTMLElement>; onItemDrop?: DragEventHandler<HTMLElement>;
id?: string;
role?: string;
'aria-label'?: string;
} }
interface State { interface State {
@ -164,7 +168,20 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
if (this.props.className) classes.push(this.props.className); if (this.props.className) classes.push(this.props.className);
return ( return (
<div ref={this.listRef} className={classes.join(' ')} style={style} onScroll={this.onScroll} onKeyDown={this.onKeyDown} onDrop={this.onDrop}> <div
ref={this.listRef}
className={classes.join(' ')}
style={style}
id={this.props.id}
role={this.props.role}
aria-label={this.props['aria-label']}
aria-setsize={items.length}
onScroll={this.onScroll}
onKeyDown={this.onKeyDown}
onDrop={this.onDrop}
>
{itemComps} {itemComps}
</div> </div>
); );

View File

@ -0,0 +1,7 @@
.flat-button {
border: none;
background: transparent;
color: inherit;
padding: 0;
}

View File

@ -0,0 +1,12 @@
.help-text {
color: var(--joplin-color);
line-height: var(--joplin-line-height);
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
margin-bottom: 10px;
&[hidden] {
display: none;
}
}

View File

@ -2,5 +2,7 @@
@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 './flat-button.scss';
@use './help-text.scss';
@use './toolbar-button.scss'; @use './toolbar-button.scss';
@use './editor-toolbar.scss'; @use './editor-toolbar.scss';

View File

@ -93,9 +93,13 @@ const getContentMarkupLanguageAndBody = (result: GotoAnythingSearchResult, notes
// result, we use this function - which returns either the item_id, if present, // result, we use this function - which returns either the item_id, if present,
// or the note ID. // or the note ID.
const getResultId = (result: GotoAnythingSearchResult) => { const getResultId = (result: GotoAnythingSearchResult) => {
return result.item_id ? result.item_id : result.id; // This ID used as a DOM ID for accessibility purposes, so it is prefixed to prevent
// name collisions.
return `goto-anything-result-${result.item_id ? result.item_id : result.id}`;
}; };
const itemListId = 'goto-anything-item-list';
class GotoAnything { class GotoAnything {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
@ -192,7 +196,6 @@ class DialogComponent extends React.PureComponent<Props, State> {
borderBottomColor: theme.dividerColor, borderBottomColor: theme.dividerColor,
boxSizing: 'border-box', boxSizing: 'border-box',
}, },
help: { ...theme.textStyle, marginBottom: 10 },
inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' }, inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' },
}; };
@ -530,10 +533,11 @@ class DialogComponent extends React.PureComponent<Props, State> {
}); });
} }
public renderItem(item: GotoAnythingSearchResult) { public renderItem(item: GotoAnythingSearchResult, index: number) {
const theme = themeStyle(this.props.themeId); const theme = themeStyle(this.props.themeId);
const style = this.style(); const style = this.style();
const isSelected = getResultId(item) === this.state.selectedItemId; const resultId = getResultId(item);
const isSelected = resultId === this.state.selectedItemId;
const rowStyle = isSelected ? style.rowSelected : style.row; const rowStyle = isSelected ? style.rowSelected : style.row;
const titleHtml = item.fragments const titleHtml = item.fragments
? `<span style="font-weight: bold; color: ${theme.color};">${item.title}</span>` ? `<span style="font-weight: bold; color: ${theme.color};">${item.title}</span>`
@ -541,12 +545,25 @@ class DialogComponent extends React.PureComponent<Props, State> {
const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, `<span style="color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true }); const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, `<span style="color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true });
const folderIcon = <i style={{ fontSize: theme.fontSize, marginRight: 2 }} className="fa fa-book" />; const folderIcon = <i style={{ fontSize: theme.fontSize, marginRight: 2 }} className="fa fa-book" role='img' aria-label={_('Notebook')} />;
const pathComp = !item.path ? null : <div style={style.rowPath}>{folderIcon} {item.path}</div>; const pathComp = !item.path ? null : <div style={style.rowPath}>{folderIcon} {item.path}</div>;
const fragmentComp = !fragmentsHtml ? null : <div style={style.rowFragments} dangerouslySetInnerHTML={{ __html: (fragmentsHtml) }}></div>; const fragmentComp = !fragmentsHtml ? null : <div style={style.rowFragments} dangerouslySetInnerHTML={{ __html: (fragmentsHtml) }}></div>;
return ( return (
<div key={getResultId(item)} className={isSelected ? 'selected' : null} style={rowStyle} onClick={this.listItem_onClick} data-id={item.id} data-parent-id={item.parent_id} data-type={item.type}> <div
key={resultId}
className={isSelected ? 'selected' : null}
style={rowStyle}
onClick={this.listItem_onClick}
data-id={item.id}
data-parent-id={item.parent_id}
data-type={item.type}
role='option'
id={resultId}
aria-posinset={index + 1}
>
<div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div> <div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div>
{fragmentComp} {fragmentComp}
{pathComp} {pathComp}
@ -619,6 +636,9 @@ class DialogComponent extends React.PureComponent<Props, State> {
return ( return (
<ItemList <ItemList
ref={this.itemListRef} ref={this.itemListRef}
id={itemListId}
role='listbox'
aria-label={_('Search results')}
itemHeight={style.itemHeight} itemHeight={style.itemHeight}
items={this.state.results} items={this.state.results}
style={itemListStyle} style={itemListStyle}
@ -629,14 +649,40 @@ class DialogComponent extends React.PureComponent<Props, State> {
public render() { public render() {
const style = this.style(); const style = this.style();
const helpComp = !this.state.showHelp ? null : <div className="help-text" style={style.help}>{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.')}</div>; const helpTextId = 'goto-anything-help-text';
const helpComp = (
<div
className='help-text'
aria-live='polite'
id={helpTextId}
style={style.help}
hidden={!this.state.showHelp}
>{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.')}</div>
);
return ( return (
<Dialog className="go-to-anything-dialog" onCancel={this.modalLayer_onDismiss} contentStyle={style.dialogBox}> <Dialog className='go-to-anything-dialog' onCancel={this.modalLayer_onDismiss} contentStyle={style.dialogBox}>
{helpComp} {helpComp}
<div style={style.inputHelpWrapper}> <div style={style.inputHelpWrapper}>
<input autoFocus type="text" style={style.input} ref={this.inputRef} value={this.state.query} onChange={this.input_onChange} onKeyDown={this.input_onKeyDown} /> <input
<HelpButton onClick={this.helpButton_onClick} /> autoFocus
type='text'
style={style.input}
ref={this.inputRef}
value={this.state.query}
onChange={this.input_onChange}
onKeyDown={this.input_onKeyDown}
aria-describedby={helpTextId}
aria-autocomplete='list'
aria-controls={itemListId}
aria-activedescendant={this.state.selectedItemId}
/>
<HelpButton
onClick={this.helpButton_onClick}
aria-controls={helpTextId}
aria-expanded={this.state.showHelp}
/>
</div> </div>
{this.renderList()} {this.renderList()}
</Dialog> </Dialog>