1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Accessibility: Improve note list keyboard and screen reader accessibility (#10940)

This commit is contained in:
Henry Heino 2024-08-31 08:05:01 -07:00 committed by GitHub
parent 4f2d0c8e5d
commit d2b7d64f4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 373 additions and 74 deletions

View File

@ -342,8 +342,10 @@ packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/types.js packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
@ -364,6 +366,7 @@ packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
@ -458,6 +461,7 @@ packages/app-desktop/gui/style/StyledLink.js
packages/app-desktop/gui/style/StyledMessage.js packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledTextInput.js packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js
@ -468,6 +472,7 @@ packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/GoToAnything.js packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/noteList.spec.js

5
.gitignore vendored
View File

@ -319,8 +319,10 @@ packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/types.js packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
@ -341,6 +343,7 @@ packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
@ -435,6 +438,7 @@ packages/app-desktop/gui/style/StyledLink.js
packages/app-desktop/gui/style/StyledMessage.js packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledTextInput.js packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js
@ -445,6 +449,7 @@ packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/GoToAnything.js packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/noteList.spec.js

View File

@ -23,6 +23,10 @@ import useDragAndDrop from './utils/useDragAndDrop';
import { itemIsInTrash } from '@joplin/lib/services/trash'; import { itemIsInTrash } from '@joplin/lib/services/trash';
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage'; import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import { _ } from '@joplin/lib/locale';
import useActiveDescendantId from './utils/useActiveDescendantId';
import getNoteElementIdFromJoplinId from '../NoteListItem/utils/getNoteElementIdFromJoplinId';
import useFocusVisible from './utils/useFocusVisible';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const commands = { const commands = {
@ -30,7 +34,7 @@ const commands = {
}; };
const NoteList = (props: Props) => { const NoteList = (props: Props) => {
const listRef = useRef(null); const listRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Record<string, HTMLDivElement>>({}); const itemRefs = useRef<Record<string, HTMLDivElement>>({});
const listRenderer = props.listRenderer; const listRenderer = props.listRenderer;
@ -65,7 +69,8 @@ const NoteList = (props: Props) => {
props.notes.length, props.notes.length,
); );
const focusNote = useFocusNote(itemRefs, props.notes, makeItemIndexVisible); const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
const focusNote = useFocusNote(listRef, props.notes, makeItemIndexVisible, setActiveNoteId);
const moveNote = useMoveNote( const moveNote = useMoveNote(
props.notesParentType, props.notesParentType,
@ -98,6 +103,7 @@ const NoteList = (props: Props) => {
const onNoteClick = useOnNoteClick(props.dispatch, focusNote); const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
const onKeyDown = useOnKeyDown( const onKeyDown = useOnKeyDown(
activeNoteId,
props.selectedNoteIds, props.selectedNoteIds,
moveNote, moveNote,
makeItemIndexVisible, makeItemIndexVisible,
@ -177,6 +183,10 @@ const NoteList = (props: Props) => {
// } // }
// }, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]); // }, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
const { focusVisible, onFocus, onBlur, onKeyUp } = useFocusVisible(listRef, () => {
focusNote(activeNoteId);
});
const highlightedWords = useMemo(() => { const highlightedWords = useMemo(() => {
if (props.notesParentType === 'Search') { if (props.notesParentType === 'Search') {
const query = BaseModel.byId(props.searches, props.selectedSearchId); const query = BaseModel.byId(props.searches, props.selectedSearchId);
@ -197,12 +207,13 @@ const NoteList = (props: Props) => {
}; };
const renderNotes = () => { const renderNotes = () => {
if (!props.notes.length) return null; if (!props.notes.length) return [];
const output: JSX.Element[] = []; const output: JSX.Element[] = [];
for (let i = startNoteIndex; i <= endNoteIndex; i++) { for (let i = startNoteIndex; i <= endNoteIndex; i++) {
const note = props.notes[i]; const note = props.notes[i];
const isSelected = props.selectedNoteIds.includes(note.id);
output.push( output.push(
<NoteListItem <NoteListItem
@ -222,7 +233,9 @@ const NoteList = (props: Props) => {
isProvisional={props.provisionalNoteIds.includes(note.id)} isProvisional={props.provisionalNoteIds.includes(note.id)}
flow={listRenderer.flow} flow={listRenderer.flow}
note={note} note={note}
isSelected={props.selectedNoteIds.includes(note.id)} tabIndex={-1}
focusVisible={focusVisible && activeNoteId === note.id}
isSelected={isSelected}
isWatched={props.watchedNoteFiles.includes(note.id)} isWatched={props.watchedNoteFiles.includes(note.id)}
listRenderer={listRenderer} listRenderer={listRenderer}
dispatch={props.dispatch} dispatch={props.dispatch}
@ -264,16 +277,26 @@ const NoteList = (props: Props) => {
return ( return (
<div <div
role='listbox'
aria-label={_('Notes')}
aria-activedescendant={getNoteElementIdFromJoplinId(activeNoteId)}
aria-multiselectable={true}
tabIndex={0}
onFocus={onFocus}
onBlur={onBlur}
className="note-list" className="note-list"
style={noteListStyle} style={noteListStyle}
ref={listRef} ref={listRef}
onScroll={onScroll} onScroll={onScroll}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onDrop={onDrop} onDrop={onDrop}
> >
{renderEmptyList()} {renderEmptyList()}
{renderFiller('top', topFillerStyle)} {renderFiller('top', topFillerStyle)}
<div className="notes" style={notesStyle}> <div className='notes' role='presentation' style={notesStyle}>
{renderNotes()} {renderNotes()}
</div> </div>
{renderFiller('bottom', bottomFillerStyle)} {renderFiller('bottom', bottomFillerStyle)}

View File

@ -18,6 +18,12 @@
background-color: var(--joplin-background-color); background-color: var(--joplin-background-color);
font-family: var(--joplin-font-family); font-family: var(--joplin-font-family);
} }
// focus-visible is communicated by displaying the active item in a different style.
// As such, an outline is unnecessary.
&:focus-visible {
outline: unset;
}
} }
.note-list-item { .note-list-item {

View File

@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';
import usePrevious from '@joplin/lib/hooks/usePrevious';
const useActiveDescendantId = (selectedFolderId: string, selectedNoteIds: string[]) => {
const selectedNoteIdsRef = useRef(selectedNoteIds);
selectedNoteIdsRef.current = selectedNoteIds;
const [activeNoteId, setActiveNoteId] = useState('');
useEffect(() => {
setActiveNoteId(selectedNoteIdsRef.current?.[0] ?? '');
}, [selectedFolderId]);
const previousNoteIdsRef = useRef<string[]>();
previousNoteIdsRef.current = usePrevious(selectedNoteIds);
useEffect(() => {
const previousNoteIds = previousNoteIdsRef.current ?? [];
setActiveNoteId(current => {
if (selectedNoteIds.includes(current)) {
return current;
} else {
// Prefer added items
for (const id of selectedNoteIds) {
if (!previousNoteIds.includes(id)) {
return id;
}
}
return selectedNoteIds[0] ?? '';
}
});
}, [selectedNoteIds]);
return { activeNoteId, setActiveNoteId };
};
export default useActiveDescendantId;

View File

@ -1,44 +1,33 @@
import shim from '@joplin/lib/shim'; import { useRef, useCallback, RefObject } from 'react';
import { useRef, useCallback, MutableRefObject } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
import { NoteEntity } from '@joplin/lib/services/database/types'; import { NoteEntity } from '@joplin/lib/services/database/types';
export type FocusNote = (noteId: string)=> void; export type FocusNote = (noteId: string)=> void;
type ItemRefs = MutableRefObject<Record<string, HTMLDivElement>>; type ContainerRef = RefObject<HTMLElement>;
type OnMakeIndexVisible = (i: number)=> void; type OnMakeIndexVisible = (i: number)=> void;
type OnSetActiveId = (id: string)=> void;
const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible) => { const useFocusNote = (
const focusItemIID = useRef(null); containerRef: ContainerRef, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible, setActiveNoteId: OnSetActiveId,
) => {
const notesRef = useRef(notes); const notesRef = useRef(notes);
notesRef.current = notes; notesRef.current = notes;
const focusNote: FocusNote = useCallback((noteId: string) => { const focusNote: FocusNote = useCallback((noteId: string) => {
// - We need to focus the item manually otherwise focus might be lost when the if (noteId) {
// list is scrolled and items within it are being rebuilt. setActiveNoteId(noteId);
// - We need to use an interval because when leaving the arrow pressed or scrolling
// offscreen items into view, the rendering of items might lag behind and so the
// ref is not yet available at this point.
if (!itemRefs.current[noteId]) {
const targetIndex = notesRef.current.findIndex(note => note.id === noteId);
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focusItemIID.current = shim.setInterval(() => {
if (itemRefs.current[noteId]) {
focus('useFocusNote1', itemRefs.current[noteId]);
shim.clearInterval(focusItemIID.current);
focusItemIID.current = null;
}
}, 10);
} else {
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focus('useFocusNote2', itemRefs.current[noteId]);
} }
}, [itemRefs, makeItemIndexVisible]);
// The note list container should have focus even when a note list item is visibly selected.
// The visibly focused item is determined by activeNoteId and is communicated to accessibility
// tools using aria- attributes
focus('useFocusNote', containerRef.current);
const targetIndex = notesRef.current.findIndex(note => note.id === noteId);
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
}, [containerRef, makeItemIndexVisible, setActiveNoteId]);
return focusNote; return focusNote;
}; };

View File

@ -0,0 +1,45 @@
import { useCallback, useState, useRef, RefObject } from 'react';
const useFocusVisible = (containerRef: RefObject<HTMLElement>, onFocusEnter: ()=> void) => {
const [focusVisible, setFocusVisible] = useState(false);
const onFocusEnterRef = useRef(onFocusEnter);
onFocusEnterRef.current = onFocusEnter;
const focusVisibleRef = useRef(focusVisible);
focusVisibleRef.current = focusVisible;
const onFocusVisible = useCallback(() => {
if (!focusVisibleRef.current) {
setFocusVisible(true);
onFocusEnterRef.current();
}
}, []);
const onFocus = useCallback(() => {
if (containerRef.current.matches(':focus-visible')) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onKeyUp = useCallback(() => {
if (containerRef.current.contains(document.activeElement)) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onBlur = useCallback(() => setFocusVisible(false), []);
return {
focusVisible,
onFocus,
// When focus becomes visible due to a key press, but the item was already
// focused, no new focus event is emitted and the browser :focus-visible doesn't
// change. As such, we need to handle this case ourselves.
onKeyUp,
onBlur,
};
};
export default useFocusVisible;

View File

@ -8,8 +8,11 @@ import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote'; import { FocusNote } from './useFocusNote';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType'; import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import { KeyboardEventKey } from '@joplin/lib/dom'; import { KeyboardEventKey } from '@joplin/lib/dom';
import announceForAccessibility from '../../utils/announceForAccessibility';
import { _ } from '@joplin/lib/locale';
const useOnKeyDown = ( const useOnKeyDown = (
activeNoteId: string,
selectedNoteIds: string[], selectedNoteIds: string[],
moveNote: (direction: number, inc: number)=> void, moveNote: (direction: number, inc: number)=> void,
makeItemIndexVisible: (itemIndex: number)=> void, makeItemIndexVisible: (itemIndex: number)=> void,
@ -84,18 +87,32 @@ const useOnKeyDown = (
} }
} }
event.preventDefault(); event.preventDefault();
} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) { } else if (notes.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
const noteId = noteIds[0]; const noteId = activeNoteId ?? notes[0]?.id;
let noteIndex = BaseModel.modelIndexById(notes, noteId); let noteIndex = BaseModel.modelIndexById(notes, noteId);
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex); noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
const newSelectedNote = notes[noteIndex]; const newSelectedNote = notes[noteIndex];
dispatch({ if (event.shiftKey) {
type: 'NOTE_SELECT', if (selectedNoteIds.includes(newSelectedNote.id)) {
id: newSelectedNote.id, dispatch({
}); type: 'NOTE_SELECT_REMOVE',
id: noteId,
});
}
dispatch({
type: 'NOTE_SELECT_ADD',
id: newSelectedNote.id,
});
} else {
dispatch({
type: 'NOTE_SELECT',
id: newSelectedNote.id,
});
}
makeItemIndexVisible(noteIndex); makeItemIndexVisible(noteIndex);
@ -122,8 +139,7 @@ const useOnKeyDown = (
event.preventDefault(); event.preventDefault();
const selectedNotes = BaseModel.modelsByIds(notes, noteIds); const selectedNotes = BaseModel.modelsByIds(notes, noteIds);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const todos = selectedNotes.filter(n => !!n.is_todo);
const todos = selectedNotes.filter((n: any) => !!n.is_todo);
if (!todos.length) return; if (!todos.length) return;
for (let i = 0; i < todos.length; i++) { for (let i = 0; i < todos.length; i++) {
@ -131,7 +147,10 @@ const useOnKeyDown = (
await Note.save(toggledTodo); await Note.save(toggledTodo);
} }
dispatch({ type: 'NOTE_SORT' });
focusNote(todos[0].id); focusNote(todos[0].id);
const wasCompleted = !!todos[0].todo_completed;
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
} }
if (key === 'Tab') { if (key === 'Tab') {
@ -151,7 +170,7 @@ const useOnKeyDown = (
type: 'NOTE_SELECT_ALL', type: 'NOTE_SELECT_ALL',
}); });
} }
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, dispatch, flow, itemsPerLine]); }, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine]);
return onKeyDown; return onKeyDown;

View File

@ -10,6 +10,7 @@ import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types'; import { NoteEntity } from '@joplin/lib/services/database/types';
import useRenderedNote from './utils/useRenderedNote'; import useRenderedNote from './utils/useRenderedNote';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
interface NoteItemProps { interface NoteItemProps {
dragIndex: number; dragIndex: number;
@ -26,8 +27,12 @@ interface NoteItemProps {
onDragStart: DragEventHandler; onDragStart: DragEventHandler;
style: CSSProperties; style: CSSProperties;
note: NoteEntity; note: NoteEntity;
isSelected: boolean;
isWatched: boolean; isWatched: boolean;
isSelected: boolean;
tabIndex: number;
focusVisible: boolean;
listRenderer: ListRenderer; listRenderer: ListRenderer;
columns: NoteListColumns; columns: NoteListColumns;
dispatch: Dispatch; dispatch: Dispatch;
@ -35,7 +40,7 @@ interface NoteItemProps {
const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => { const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const noteId = props.note.id; const noteId = props.note.id;
const elementId = `list-note-${noteId}`; const elementId = getNoteElementIdFromJoplinId(noteId);
const onInputChange: OnInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => { const onInputChange: OnInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const getValue = (element: HTMLInputElement) => { const getValue = (element: HTMLInputElement) => {
@ -70,6 +75,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
rootElement, rootElement,
noteId, noteId,
renderedNote ? renderedNote.html : '', renderedNote ? renderedNote.html : '',
props.focusVisible,
props.style, props.style,
props.itemSize, props.itemSize,
props.onClick, props.onClick,
@ -147,13 +153,18 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
id={elementId} id={elementId}
ref={ref} ref={ref}
draggable={true} draggable={true}
tabIndex={0} tabIndex={props.tabIndex}
className={className} className={className}
data-id={noteId} data-id={noteId}
style={{ height: props.itemSize.height }} style={{ height: props.itemSize.height }}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onDragStart={props.onDragStart} onDragStart={props.onDragStart}
onDragOver={props.onDragOver} onDragOver={props.onDragOver}
aria-selected={props.isSelected}
aria-posinset={1 + props.index}
aria-setsize={props.noteCount}
role='option'
> >
<div className="dragcursor" style={dragCursorStyle}></div> <div className="dragcursor" style={dragCursorStyle}></div>
</div>; </div>;

View File

@ -0,0 +1,4 @@
const getNoteElementIdFromJoplinId = (id: string) => `list-note-${id}`;
export default getNoteElementIdFromJoplinId;

View File

@ -2,6 +2,7 @@ import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteLis
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types'; import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
const prepareViewProps = async ( const prepareViewProps = async (
dependencies: ListRendererDependency[], dependencies: ListRendererDependency[],
@ -33,6 +34,12 @@ const prepareViewProps = async (
} else if (dep === 'note.folder.title') { } else if (dep === 'note.folder.title') {
if (!output.note.folder) output.note.folder = {}; if (!output.note.folder) output.note.folder = {};
output.note.folder[propName] = folder.title; output.note.folder[propName] = folder.title;
} else if (dep === 'note.todoStatusText') {
let taskStatus = '';
if (note.is_todo) {
taskStatus = note.todo_completed ? _('Complete to-do') : _('Incomplete to-do');
}
output.note[propName] = taskStatus;
} else { } else {
// The notes in the state only contain the properties defined in // The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a // Note.previewFields(). It means that if a view request a

View File

@ -3,8 +3,9 @@ import { Size } from '@joplin/utils/types';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType'; import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const useItemElement = (
const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => { rootElement: HTMLDivElement, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow,
) => {
const [itemElement, setItemElement] = useState<HTMLDivElement>(null); const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -32,6 +33,16 @@ const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: s
}; };
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]); }, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]);
useEffect(() => {
if (!itemElement) return;
if (focusVisible) {
itemElement.classList.add('-focus-visible');
} else {
itemElement.classList.remove('-focus-visible');
}
}, [focusVisible, itemElement]);
return itemElement; return itemElement;
}; };

View File

@ -0,0 +1,33 @@
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('announceForAccessibility');
const announceForAccessibility = (message: string) => {
const id = 'joplin-announce-for-accessibility-live-region';
let announcementArea = document.querySelector(`#${id}`);
if (!announcementArea) {
announcementArea = document.createElement('div');
announcementArea.ariaLive = 'polite';
announcementArea.role = 'status';
announcementArea.id = id;
announcementArea.classList.add('visually-hidden');
document.body.appendChild(announcementArea);
} else {
// Allows messages to be re-announced.
announcementArea.textContent = '';
}
// Defer the announcement. Because `aria-live: polite` causes **changes** in
// content to be announced, a slight delay is needed when there would otherwise
// be no change in content.
const announce = () => {
logger.debug('Announcing:', message);
announcementArea.textContent = message;
};
shim.setTimeout(announce, 100);
};
export default announceForAccessibility;

View File

@ -24,7 +24,7 @@ test.describe('main', () => {
const editor = await mainScreen.createNewNote('Test note'); const editor = await mainScreen.createNewNote('Test note');
// Note list should contain the new note // Note list should contain the new note
await expect(mainScreen.noteListContainer.getByText('Test note')).toBeVisible(); await expect(mainScreen.noteList.getNoteItemByTitle('Test note')).toBeVisible();
// Focus the editor // Focus the editor
await editor.codeMirrorEditor.click(); await editor.codeMirrorEditor.click();

View File

@ -15,7 +15,8 @@ test.describe('markdownEditor', () => {
await importedFolder.waitFor(); await importedFolder.waitFor();
await importedFolder.click(); await importedFolder.click();
const importedHtmlFileItem = mainScreen.noteListContainer.getByText('test-html-file-with-image'); await mainScreen.noteList.focusContent(electronApp);
const importedHtmlFileItem = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await importedHtmlFileItem.click(); await importedHtmlFileItem.click();
const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe(); const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();

View File

@ -4,10 +4,11 @@ import activateMainMenuItem from '../util/activateMainMenuItem';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import GoToAnything from './GoToAnything'; import GoToAnything from './GoToAnything';
import setFilePickerResponse from '../util/setFilePickerResponse'; import setFilePickerResponse from '../util/setFilePickerResponse';
import NoteList from './NoteList';
export default class MainScreen { export default class MainScreen {
public readonly newNoteButton: Locator; public readonly newNoteButton: Locator;
public readonly noteListContainer: Locator; public readonly noteList: NoteList;
public readonly sidebar: Sidebar; public readonly sidebar: Sidebar;
public readonly dialog: Locator; public readonly dialog: Locator;
public readonly noteEditor: NoteEditorScreen; public readonly noteEditor: NoteEditorScreen;
@ -15,7 +16,7 @@ export default class MainScreen {
public constructor(private page: Page) { public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button'); this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList'); this.noteList = new NoteList(page);
this.sidebar = new Sidebar(page, this); this.sidebar = new Sidebar(page, this);
this.dialog = page.locator('.dialog-modal-layer'); this.dialog = page.locator('.dialog-modal-layer');
this.noteEditor = new NoteEditorScreen(page); this.noteEditor = new NoteEditorScreen(page);
@ -24,7 +25,7 @@ export default class MainScreen {
public async waitFor() { public async waitFor() {
await this.newNoteButton.waitFor(); await this.newNoteButton.waitFor();
await this.noteListContainer.waitFor(); await this.noteList.waitFor();
} }
// Follows the steps a user would use to create a new note. // Follows the steps a user would use to create a new note.

View File

@ -0,0 +1,38 @@
import activateMainMenuItem from '../util/activateMainMenuItem';
import { ElectronApplication, Locator, Page, expect } from '@playwright/test';
export default class NoteList {
public readonly container: Locator;
public constructor(page: Page) {
this.container = page.locator('.rli-noteList');
}
public waitFor() {
return this.container.waitFor();
}
private async sortBy(electronApp: ElectronApplication, sortMethod: string) {
const success = await activateMainMenuItem(electronApp, sortMethod, 'Sort notes by');
if (!success) {
throw new Error(`Unable to find sorting menu item: ${sortMethod}`);
}
}
public async sortByTitle(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Title');
}
public async focusContent(electronApp: ElectronApplication) {
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
}
// The resultant locator may fail to resolve if the item is not visible
public getNoteItemByTitle(title: string|RegExp) {
return this.container.getByRole('option', { name: title });
}
public async expectNoteToBeSelected(title: string|RegExp) {
await expect(this.getNoteItemByTitle(title)).toHaveAttribute('aria-selected', 'true');
}
}

View File

@ -1,10 +1,9 @@
import { test, expect } from './util/test'; import { test, expect } from './util/test';
import MainScreen from './models/MainScreen'; import MainScreen from './models/MainScreen';
import activateMainMenuItem from './util/activateMainMenuItem';
import setMessageBoxResponse from './util/setMessageBoxResponse'; import setMessageBoxResponse from './util/setMessageBoxResponse';
test.describe('noteList', () => { test.describe('noteList', () => {
test('should be possible to edit notes in a different notebook when searching', async ({ mainWindow }) => { test('should be possible to edit notes in a different notebook when searching', async ({ mainWindow, electronApp }) => {
const mainScreen = new MainScreen(mainWindow); const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar; const sidebar = mainScreen.sidebar;
@ -22,7 +21,8 @@ test.describe('noteList', () => {
// Search for and focus a note different from the folder we were in before searching. // Search for and focus a note different from the folder we were in before searching.
await mainScreen.search('/note-1'); await mainScreen.search('/note-1');
const note1Result = mainScreen.noteListContainer.getByText('note-1'); await mainScreen.noteList.focusContent(electronApp);
const note1Result = mainScreen.noteList.getNoteItemByTitle('note-1');
await expect(note1Result).toBeAttached(); await expect(note1Result).toBeAttached();
await note1Result.click(); await note1Result.click();
@ -49,9 +49,10 @@ test.describe('noteList', () => {
await mainScreen.createNewNote('test note 1'); await mainScreen.createNewNote('test note 1');
await mainScreen.createNewNote('test note 2'); await mainScreen.createNewNote('test note 2');
await activateMainMenuItem(electronApp, 'Note list', 'Focus'); const noteList = mainScreen.noteList;
await expect(mainScreen.noteListContainer.getByText('test note 1')).toBeVisible(); await noteList.focusContent(electronApp);
await expect(mainScreen.noteListContainer.getByText('test note 2')).toBeVisible(); await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
await expect(noteList.getNoteItemByTitle('test note 2')).toBeVisible();
await setMessageBoxResponse(electronApp, /^Delete/i); await setMessageBoxResponse(electronApp, /^Delete/i);
@ -62,7 +63,7 @@ test.describe('noteList', () => {
await mainWindow.keyboard.up('Shift'); await mainWindow.keyboard.up('Shift');
}; };
await pressShiftDelete(); await pressShiftDelete();
await expect(mainScreen.noteListContainer.getByText('test note 2')).not.toBeVisible(); await expect(noteList.getNoteItemByTitle('test note 2')).not.toBeVisible();
// Should not delete when the editor is focused // Should not delete when the editor is focused
await mainScreen.noteEditor.focusCodeMirrorEditor(); await mainScreen.noteEditor.focusCodeMirrorEditor();
@ -71,6 +72,35 @@ test.describe('noteList', () => {
await folderBHeader.click(); await folderBHeader.click();
await folderAHeader.click(); await folderAHeader.click();
await expect(mainScreen.noteListContainer.getByText('test note 1')).toBeVisible(); await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
});
test('arrow keys should navigate the note list', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
await sidebar.createNewFolder('Folder');
await mainScreen.createNewNote('note_1');
await mainScreen.createNewNote('note_2');
await mainScreen.createNewNote('note_3');
await mainScreen.createNewNote('note_4');
const noteList = mainScreen.noteList;
await noteList.sortByTitle(electronApp);
await noteList.focusContent(electronApp);
// The most recently-created note should be visible
const note4Item = noteList.getNoteItemByTitle('note_4');
await expect(note4Item).toBeVisible();
await noteList.expectNoteToBeSelected('note_4');
await noteList.container.press('ArrowUp');
await noteList.expectNoteToBeSelected('note_3');
await noteList.container.press('ArrowUp');
await noteList.expectNoteToBeSelected('note_2');
await noteList.container.press('ArrowDown');
await noteList.expectNoteToBeSelected('note_3');
}); });
}); });

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.getByRole('button', { name: 'Toggle sort order' }); const sortOrderLocator = mainScreen.noteList.container.getByRole('button', { name: 'Toggle sort order' });
await expect(sortOrderLocator).toBeVisible(); await expect(sortOrderLocator).toBeVisible();
await mainScreen.openSettings(electronApp); await mainScreen.openSettings(electronApp);

View File

@ -249,6 +249,22 @@ p.info-text {
color: var(--joplin-color-faded); color: var(--joplin-color-faded);
} }
// Hides elements, but still exposes them to accessibility tools.
// Avoids `visibility: hidden` and `display: none`, because this may cause some
// screen readers to ignore the element.
// See https://www.w3.org/WAI/tutorials/forms/labels/#hiding-label-text
.visually-hidden {
opacity: 0;
z-index: -1;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
}
/* ========================================================================================= /* =========================================================================================
Component-specific classes Component-specific classes
========================================================================================= */ ========================================================================================= */

View File

@ -5,7 +5,7 @@ import time from './time';
import JoplinDatabase, { TableField } from './JoplinDatabase'; import JoplinDatabase, { TableField } from './JoplinDatabase';
import { LoadOptions, SaveOptions } from './models/utils/types'; import { LoadOptions, SaveOptions } from './models/utils/types';
import ActionLogger, { ItemActionType as ItemActionType } from './utils/ActionLogger'; import ActionLogger, { ItemActionType as ItemActionType } from './utils/ActionLogger';
import { SqlQuery } from './services/database/types'; import { BaseItemEntity, SqlQuery } from './services/database/types';
const Mutex = require('async-mutex').Mutex; const Mutex = require('async-mutex').Mutex;
// New code should make use of this enum // New code should make use of this enum
@ -167,8 +167,7 @@ class BaseModel {
return -1; return -1;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public static modelsByIds<T extends BaseItemEntity>(items: T[], ids: string[]): T[] {
public static modelsByIds(items: any[], ids: string[]) {
const output = []; const output = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (ids.indexOf(items[i].id) >= 0) { if (ids.indexOf(items[i].id) >= 0) {

View File

@ -40,6 +40,7 @@ const renderer: ListRenderer = {
'note.isWatched', 'note.isWatched',
'note.title', 'note.title',
'note.todo_completed', 'note.todo_completed',
'note.todoStatusText',
], ],
itemCss: // css itemCss: // css
@ -57,7 +58,7 @@ const renderer: ListRenderer = {
background-color: var(--joplin-selected-color); background-color: var(--joplin-selected-color);
} }
&:hover, :focus-visible > & > .content { &:hover, &.-focus-visible > .content {
background-color: var(--joplin-background-color-hover3); background-color: var(--joplin-background-color-hover3);
} }
@ -133,7 +134,13 @@ const renderer: ListRenderer = {
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}"> <div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
{{#note.is_todo}} {{#note.is_todo}}
<div class="checkbox"> <div class="checkbox">
<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}> <input
data-id="todo-checkbox"
type="checkbox"
aria-label="{{note.todoStatusText}}"
tabindex="-1"
{{#note.todo_completed}}checked="checked"{{/note.todo_completed}}
>
</div> </div>
{{/note.is_todo}} {{/note.is_todo}}
<div class="title" data-id="{{note.id}}"> <div class="title" data-id="{{note.id}}">

View File

@ -33,10 +33,6 @@ const renderer: ListRenderer = {
display: flex; display: flex;
height: 100%; height: 100%;
&:hover, :focus-visible {
background-color: var(--joplin-background-color-hover3);
}
> .item { > .item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -83,6 +79,10 @@ const renderer: ListRenderer = {
> .row.-completed { > .row.-completed {
opacity: 0.5; opacity: 0.5;
} }
> .row:hover, &.-focus-visible > .row {
background-color: var(--joplin-background-color-hover3);
}
`, `,
onHeaderClick: async (event: OnClickEvent) => { onHeaderClick: async (event: OnClickEvent) => {
@ -108,7 +108,12 @@ const renderer: ListRenderer = {
` `
{{#note.is_todo}} {{#note.is_todo}}
<div class="checkbox"> <div class="checkbox">
<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}> <input
data-id="todo-checkbox"
type="checkbox"
aria-label="{{note.todoStatusText}}"
tabindex="-1"
{{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
</div> </div>
{{/note.is_todo}} {{/note.is_todo}}
`, `,

View File

@ -35,6 +35,10 @@ export type OnClickHandler = (event: OnClickEvent)=> Promise<void>;
* complemented with special properties such as `note.isWatched`, to know if a note is currently * complemented with special properties such as `note.isWatched`, to know if a note is currently
* opened in the external editor, and `note.tags` to get the list tags associated with the note. * opened in the external editor, and `note.tags` to get the list tags associated with the note.
* *
* The `note.todoStatusText` property is a localised description of the to-do status (e.g.
* "to-do, incomplete"). If you include an `<input type='checkbox' ... />` for to-do items that would
* otherwise be unlabelled, consider adding `note.todoStatusText` as the checkbox's `aria-label`.
*
* ## Item properties * ## Item properties
* *
* The `item.*` properties are specific to the rendered item. The most important being * The `item.*` properties are specific to the rendered item. The most important being
@ -49,6 +53,7 @@ export type ListRendererDependency =
'note.folder.title' | 'note.folder.title' |
'note.isWatched' | 'note.isWatched' |
'note.tags' | 'note.tags' |
'note.todoStatusText' |
'note.titleHtml'; 'note.titleHtml';
export type ListRendererItemValueTemplates = Record<string, string>; export type ListRendererItemValueTemplates = Record<string, string>;

View File

@ -129,4 +129,5 @@ entypo
Zocial Zocial
agplv agplv
Famegear Famegear
rcompare rcompare
tabindex