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:
parent
4f2d0c8e5d
commit
d2b7d64f4f
@ -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/utils/canManuallySortNotes.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/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
packages/app-desktop/gui/NoteList/utils/useItemCss.js
|
||||
packages/app-desktop/gui/NoteList/utils/useMoveNote.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.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/prepareViewProps.test.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/StyledTextInput.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/dragAndDrop.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/MainScreen.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/Sidebar.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/utils/canManuallySortNotes.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/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
packages/app-desktop/gui/NoteList/utils/useItemCss.js
|
||||
packages/app-desktop/gui/NoteList/utils/useMoveNote.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.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/prepareViewProps.test.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/StyledTextInput.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/dragAndDrop.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/MainScreen.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/Sidebar.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
|
@ -23,6 +23,10 @@ import useDragAndDrop from './utils/useDragAndDrop';
|
||||
import { itemIsInTrash } from '@joplin/lib/services/trash';
|
||||
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
|
||||
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 commands = {
|
||||
@ -30,7 +34,7 @@ const commands = {
|
||||
};
|
||||
|
||||
const NoteList = (props: Props) => {
|
||||
const listRef = useRef(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
const listRenderer = props.listRenderer;
|
||||
|
||||
@ -65,7 +69,8 @@ const NoteList = (props: Props) => {
|
||||
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(
|
||||
props.notesParentType,
|
||||
@ -98,6 +103,7 @@ const NoteList = (props: Props) => {
|
||||
const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
|
||||
|
||||
const onKeyDown = useOnKeyDown(
|
||||
activeNoteId,
|
||||
props.selectedNoteIds,
|
||||
moveNote,
|
||||
makeItemIndexVisible,
|
||||
@ -177,6 +183,10 @@ const NoteList = (props: Props) => {
|
||||
// }
|
||||
// }, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
|
||||
|
||||
const { focusVisible, onFocus, onBlur, onKeyUp } = useFocusVisible(listRef, () => {
|
||||
focusNote(activeNoteId);
|
||||
});
|
||||
|
||||
const highlightedWords = useMemo(() => {
|
||||
if (props.notesParentType === 'Search') {
|
||||
const query = BaseModel.byId(props.searches, props.selectedSearchId);
|
||||
@ -197,12 +207,13 @@ const NoteList = (props: Props) => {
|
||||
};
|
||||
|
||||
const renderNotes = () => {
|
||||
if (!props.notes.length) return null;
|
||||
if (!props.notes.length) return [];
|
||||
|
||||
const output: JSX.Element[] = [];
|
||||
|
||||
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
|
||||
const note = props.notes[i];
|
||||
const isSelected = props.selectedNoteIds.includes(note.id);
|
||||
|
||||
output.push(
|
||||
<NoteListItem
|
||||
@ -222,7 +233,9 @@ const NoteList = (props: Props) => {
|
||||
isProvisional={props.provisionalNoteIds.includes(note.id)}
|
||||
flow={listRenderer.flow}
|
||||
note={note}
|
||||
isSelected={props.selectedNoteIds.includes(note.id)}
|
||||
tabIndex={-1}
|
||||
focusVisible={focusVisible && activeNoteId === note.id}
|
||||
isSelected={isSelected}
|
||||
isWatched={props.watchedNoteFiles.includes(note.id)}
|
||||
listRenderer={listRenderer}
|
||||
dispatch={props.dispatch}
|
||||
@ -264,16 +277,26 @@ const NoteList = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role='listbox'
|
||||
aria-label={_('Notes')}
|
||||
aria-activedescendant={getNoteElementIdFromJoplinId(activeNoteId)}
|
||||
aria-multiselectable={true}
|
||||
tabIndex={0}
|
||||
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
className="note-list"
|
||||
style={noteListStyle}
|
||||
ref={listRef}
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{renderEmptyList()}
|
||||
{renderFiller('top', topFillerStyle)}
|
||||
<div className="notes" style={notesStyle}>
|
||||
<div className='notes' role='presentation' style={notesStyle}>
|
||||
{renderNotes()}
|
||||
</div>
|
||||
{renderFiller('bottom', bottomFillerStyle)}
|
||||
|
@ -18,6 +18,12 @@
|
||||
background-color: var(--joplin-background-color);
|
||||
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 {
|
||||
|
@ -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;
|
@ -1,44 +1,33 @@
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { useRef, useCallback, MutableRefObject } from 'react';
|
||||
import { useRef, useCallback, RefObject } from 'react';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
export type FocusNote = (noteId: string)=> void;
|
||||
type ItemRefs = MutableRefObject<Record<string, HTMLDivElement>>;
|
||||
type ContainerRef = RefObject<HTMLElement>;
|
||||
type OnMakeIndexVisible = (i: number)=> void;
|
||||
type OnSetActiveId = (id: string)=> void;
|
||||
|
||||
const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible) => {
|
||||
const focusItemIID = useRef(null);
|
||||
|
||||
const useFocusNote = (
|
||||
containerRef: ContainerRef, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible, setActiveNoteId: OnSetActiveId,
|
||||
) => {
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
|
||||
const focusNote: FocusNote = useCallback((noteId: string) => {
|
||||
// - We need to focus the item manually otherwise focus might be lost when the
|
||||
// list is scrolled and items within it are being rebuilt.
|
||||
// - 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]);
|
||||
if (noteId) {
|
||||
setActiveNoteId(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;
|
||||
};
|
||||
|
45
packages/app-desktop/gui/NoteList/utils/useFocusVisible.ts
Normal file
45
packages/app-desktop/gui/NoteList/utils/useFocusVisible.ts
Normal 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;
|
@ -8,8 +8,11 @@ import { Dispatch } from 'redux';
|
||||
import { FocusNote } from './useFocusNote';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import { KeyboardEventKey } from '@joplin/lib/dom';
|
||||
import announceForAccessibility from '../../utils/announceForAccessibility';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
const useOnKeyDown = (
|
||||
activeNoteId: string,
|
||||
selectedNoteIds: string[],
|
||||
moveNote: (direction: number, inc: number)=> void,
|
||||
makeItemIndexVisible: (itemIndex: number)=> void,
|
||||
@ -84,18 +87,32 @@ const useOnKeyDown = (
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
|
||||
const noteId = noteIds[0];
|
||||
} else if (notes.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
|
||||
const noteId = activeNoteId ?? notes[0]?.id;
|
||||
let noteIndex = BaseModel.modelIndexById(notes, noteId);
|
||||
|
||||
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
|
||||
|
||||
const newSelectedNote = notes[noteIndex];
|
||||
|
||||
dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: newSelectedNote.id,
|
||||
});
|
||||
if (event.shiftKey) {
|
||||
if (selectedNoteIds.includes(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);
|
||||
|
||||
@ -122,8 +139,7 @@ const useOnKeyDown = (
|
||||
event.preventDefault();
|
||||
|
||||
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: any) => !!n.is_todo);
|
||||
const todos = selectedNotes.filter(n => !!n.is_todo);
|
||||
if (!todos.length) return;
|
||||
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
@ -131,7 +147,10 @@ const useOnKeyDown = (
|
||||
await Note.save(toggledTodo);
|
||||
}
|
||||
|
||||
dispatch({ type: 'NOTE_SORT' });
|
||||
focusNote(todos[0].id);
|
||||
const wasCompleted = !!todos[0].todo_completed;
|
||||
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
|
||||
}
|
||||
|
||||
if (key === 'Tab') {
|
||||
@ -151,7 +170,7 @@ const useOnKeyDown = (
|
||||
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;
|
||||
|
@ -10,6 +10,7 @@ import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
import { Dispatch } from 'redux';
|
||||
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
|
||||
|
||||
interface NoteItemProps {
|
||||
dragIndex: number;
|
||||
@ -26,8 +27,12 @@ interface NoteItemProps {
|
||||
onDragStart: DragEventHandler;
|
||||
style: CSSProperties;
|
||||
note: NoteEntity;
|
||||
isSelected: boolean;
|
||||
isWatched: boolean;
|
||||
|
||||
isSelected: boolean;
|
||||
tabIndex: number;
|
||||
focusVisible: boolean;
|
||||
|
||||
listRenderer: ListRenderer;
|
||||
columns: NoteListColumns;
|
||||
dispatch: Dispatch;
|
||||
@ -35,7 +40,7 @@ interface NoteItemProps {
|
||||
|
||||
const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
const noteId = props.note.id;
|
||||
const elementId = `list-note-${noteId}`;
|
||||
const elementId = getNoteElementIdFromJoplinId(noteId);
|
||||
|
||||
const onInputChange: OnInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const getValue = (element: HTMLInputElement) => {
|
||||
@ -70,6 +75,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
rootElement,
|
||||
noteId,
|
||||
renderedNote ? renderedNote.html : '',
|
||||
props.focusVisible,
|
||||
props.style,
|
||||
props.itemSize,
|
||||
props.onClick,
|
||||
@ -147,13 +153,18 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
id={elementId}
|
||||
ref={ref}
|
||||
draggable={true}
|
||||
tabIndex={0}
|
||||
tabIndex={props.tabIndex}
|
||||
className={className}
|
||||
data-id={noteId}
|
||||
style={{ height: props.itemSize.height }}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDragStart={props.onDragStart}
|
||||
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>;
|
||||
|
@ -0,0 +1,4 @@
|
||||
|
||||
const getNoteElementIdFromJoplinId = (id: string) => `list-note-${id}`;
|
||||
|
||||
export default getNoteElementIdFromJoplinId;
|
@ -2,6 +2,7 @@ import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteLis
|
||||
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
const prepareViewProps = async (
|
||||
dependencies: ListRendererDependency[],
|
||||
@ -33,6 +34,12 @@ const prepareViewProps = async (
|
||||
} else if (dep === 'note.folder.title') {
|
||||
if (!output.note.folder) output.note.folder = {};
|
||||
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 {
|
||||
// The notes in the state only contain the properties defined in
|
||||
// Note.previewFields(). It means that if a view request a
|
||||
|
@ -3,8 +3,9 @@ import { Size } from '@joplin/utils/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => {
|
||||
const useItemElement = (
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@ -32,6 +33,16 @@ const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: s
|
||||
};
|
||||
}, [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;
|
||||
};
|
||||
|
||||
|
33
packages/app-desktop/gui/utils/announceForAccessibility.ts
Normal file
33
packages/app-desktop/gui/utils/announceForAccessibility.ts
Normal 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;
|
@ -24,7 +24,7 @@ test.describe('main', () => {
|
||||
const editor = await mainScreen.createNewNote('Test 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
|
||||
await editor.codeMirrorEditor.click();
|
||||
|
@ -15,7 +15,8 @@ test.describe('markdownEditor', () => {
|
||||
await importedFolder.waitFor();
|
||||
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();
|
||||
|
||||
const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();
|
||||
|
@ -4,10 +4,11 @@ import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import Sidebar from './Sidebar';
|
||||
import GoToAnything from './GoToAnything';
|
||||
import setFilePickerResponse from '../util/setFilePickerResponse';
|
||||
import NoteList from './NoteList';
|
||||
|
||||
export default class MainScreen {
|
||||
public readonly newNoteButton: Locator;
|
||||
public readonly noteListContainer: Locator;
|
||||
public readonly noteList: NoteList;
|
||||
public readonly sidebar: Sidebar;
|
||||
public readonly dialog: Locator;
|
||||
public readonly noteEditor: NoteEditorScreen;
|
||||
@ -15,7 +16,7 @@ export default class MainScreen {
|
||||
|
||||
public constructor(private page: Page) {
|
||||
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.dialog = page.locator('.dialog-modal-layer');
|
||||
this.noteEditor = new NoteEditorScreen(page);
|
||||
@ -24,7 +25,7 @@ export default class MainScreen {
|
||||
|
||||
public async 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.
|
||||
|
38
packages/app-desktop/integration-tests/models/NoteList.ts
Normal file
38
packages/app-desktop/integration-tests/models/NoteList.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -1,10 +1,9 @@
|
||||
import { test, expect } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
import activateMainMenuItem from './util/activateMainMenuItem';
|
||||
import setMessageBoxResponse from './util/setMessageBoxResponse';
|
||||
|
||||
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 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.
|
||||
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 note1Result.click();
|
||||
|
||||
@ -49,9 +49,10 @@ test.describe('noteList', () => {
|
||||
await mainScreen.createNewNote('test note 1');
|
||||
await mainScreen.createNewNote('test note 2');
|
||||
|
||||
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
|
||||
await expect(mainScreen.noteListContainer.getByText('test note 1')).toBeVisible();
|
||||
await expect(mainScreen.noteListContainer.getByText('test note 2')).toBeVisible();
|
||||
const noteList = mainScreen.noteList;
|
||||
await noteList.focusContent(electronApp);
|
||||
await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
|
||||
await expect(noteList.getNoteItemByTitle('test note 2')).toBeVisible();
|
||||
|
||||
await setMessageBoxResponse(electronApp, /^Delete/i);
|
||||
|
||||
@ -62,7 +63,7 @@ test.describe('noteList', () => {
|
||||
await mainWindow.keyboard.up('Shift');
|
||||
};
|
||||
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
|
||||
await mainScreen.noteEditor.focusCodeMirrorEditor();
|
||||
@ -71,6 +72,35 @@ test.describe('noteList', () => {
|
||||
|
||||
await folderBHeader.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');
|
||||
});
|
||||
});
|
||||
|
@ -8,7 +8,7 @@ test.describe('settings', () => {
|
||||
await mainScreen.waitFor();
|
||||
|
||||
// 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 mainScreen.openSettings(electronApp);
|
||||
|
@ -249,6 +249,22 @@ p.info-text {
|
||||
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
|
||||
========================================================================================= */
|
||||
|
@ -5,7 +5,7 @@ import time from './time';
|
||||
import JoplinDatabase, { TableField } from './JoplinDatabase';
|
||||
import { LoadOptions, SaveOptions } from './models/utils/types';
|
||||
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;
|
||||
|
||||
// New code should make use of this enum
|
||||
@ -167,8 +167,7 @@ class BaseModel {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static modelsByIds(items: any[], ids: string[]) {
|
||||
public static modelsByIds<T extends BaseItemEntity>(items: T[], ids: string[]): T[] {
|
||||
const output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (ids.indexOf(items[i].id) >= 0) {
|
||||
|
@ -40,6 +40,7 @@ const renderer: ListRenderer = {
|
||||
'note.isWatched',
|
||||
'note.title',
|
||||
'note.todo_completed',
|
||||
'note.todoStatusText',
|
||||
],
|
||||
|
||||
itemCss: // css
|
||||
@ -57,7 +58,7 @@ const renderer: ListRenderer = {
|
||||
background-color: var(--joplin-selected-color);
|
||||
}
|
||||
|
||||
&:hover, :focus-visible > & > .content {
|
||||
&:hover, &.-focus-visible > .content {
|
||||
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}}">
|
||||
{{#note.is_todo}}
|
||||
<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>
|
||||
{{/note.is_todo}}
|
||||
<div class="title" data-id="{{note.id}}">
|
||||
|
@ -33,10 +33,6 @@ const renderer: ListRenderer = {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
&:hover, :focus-visible {
|
||||
background-color: var(--joplin-background-color-hover3);
|
||||
}
|
||||
|
||||
> .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -83,6 +79,10 @@ const renderer: ListRenderer = {
|
||||
> .row.-completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .row:hover, &.-focus-visible > .row {
|
||||
background-color: var(--joplin-background-color-hover3);
|
||||
}
|
||||
`,
|
||||
|
||||
onHeaderClick: async (event: OnClickEvent) => {
|
||||
@ -108,7 +108,12 @@ const renderer: ListRenderer = {
|
||||
`
|
||||
{{#note.is_todo}}
|
||||
<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>
|
||||
{{/note.is_todo}}
|
||||
`,
|
||||
|
@ -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
|
||||
* 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
|
||||
*
|
||||
* The `item.*` properties are specific to the rendered item. The most important being
|
||||
@ -49,6 +53,7 @@ export type ListRendererDependency =
|
||||
'note.folder.title' |
|
||||
'note.isWatched' |
|
||||
'note.tags' |
|
||||
'note.todoStatusText' |
|
||||
'note.titleHtml';
|
||||
|
||||
export type ListRendererItemValueTemplates = Record<string, string>;
|
||||
|
@ -129,4 +129,5 @@ entypo
|
||||
Zocial
|
||||
agplv
|
||||
Famegear
|
||||
rcompare
|
||||
rcompare
|
||||
tabindex
|
Loading…
Reference in New Issue
Block a user