Skip to content

Commit

Permalink
Desktop: Accessibility: Improve note list accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
personalizedrefrigerator committed Aug 28, 2024
1 parent e607a73 commit 8c25ff1
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 18 deletions.
54 changes: 47 additions & 7 deletions packages/app-desktop/gui/NoteList/NoteList2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,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;

Expand Down Expand Up @@ -65,7 +65,7 @@ const NoteList = (props: Props) => {
props.notes.length,
);

const focusNote = useFocusNote(itemRefs, props.notes, makeItemIndexVisible);
const focusNote = useFocusNote(listRef, itemRefs, props.notes, makeItemIndexVisible);

const moveNote = useMoveNote(
props.notesParentType,
Expand Down Expand Up @@ -196,15 +196,42 @@ const NoteList = (props: Props) => {
return <div key={key} style={style}></div>;
};

let renderedSelectedItem = false;
const renderNotes = () => {
if (!props.notes.length) return null;

const output: JSX.Element[] = [];
const firstRowIndex = Math.floor(startNoteIndex / itemsPerLine);
const rows: JSX.Element[] = [];
let currentRow: JSX.Element[] = [];

const finalizeRow = () => {
if (currentRow.length === 0) {
return;
}

// Rows are 1-indexed
const rowIndex = firstRowIndex + rows.length + 1;
rows.push(
<div
key={`row-${rowIndex}`}
role='row'
className='row'
aria-rowindex={rowIndex}
>
{currentRow}
</div>,
);
currentRow = [];
};

for (let i = startNoteIndex; i <= endNoteIndex; i++) {
const note = props.notes[i];

output.push(
const isSelected = props.selectedNoteIds.includes(note.id);
renderedSelectedItem ||= isSelected;
const isFocusable = isSelected;

currentRow.push(
<NoteListItem
key={note.id}
ref={el => itemRefs.current[note.id] = el}
Expand All @@ -222,16 +249,22 @@ const NoteList = (props: Props) => {
isProvisional={props.provisionalNoteIds.includes(note.id)}
flow={listRenderer.flow}
note={note}
isSelected={props.selectedNoteIds.includes(note.id)}
tabIndex={isFocusable ? 0 : -1}
isSelected={isSelected}
isWatched={props.watchedNoteFiles.includes(note.id)}
listRenderer={listRenderer}
dispatch={props.dispatch}
columns={props.columns}
/>,
);

if (currentRow.length >= itemsPerLine) {
finalizeRow();
}
}
finalizeRow();

return output;
return rows;
};

const topFillerHeight = startLineIndex * itemSize.height;
Expand Down Expand Up @@ -264,6 +297,13 @@ const NoteList = (props: Props) => {

return (
<div
role='grid'
aria-colcount={itemsPerLine}
aria-rowcount={totalLineCount}
// Ensure that the note list can be focused, even if no selected
// items are visible.
tabIndex={!renderedSelectedItem ? 0 : undefined}

className="note-list"
style={noteListStyle}
ref={listRef}
Expand All @@ -273,7 +313,7 @@ const NoteList = (props: Props) => {
>
{renderEmptyList()}
{renderFiller('top', topFillerStyle)}
<div className="notes" style={notesStyle}>
<div className="notes note-list-grid" style={notesStyle}>
{renderNotes()}
</div>
{renderFiller('bottom', bottomFillerStyle)}
Expand Down
8 changes: 8 additions & 0 deletions packages/app-desktop/gui/NoteList/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
}
}

.note-list-grid {
> .row {
display: flex;
flex-direction: row;
}
}

.note-list-item {
display: flex;
}
Expand All @@ -28,6 +35,7 @@
border-color: var(--joplin-color);
position: relative;
box-sizing: border-box;
flex-grow: 1;

> .dragcursor {
background-color: var(--joplin-color);
Expand Down
11 changes: 8 additions & 3 deletions packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import shim from '@joplin/lib/shim';
import { useRef, useCallback, MutableRefObject } from 'react';
import { useRef, useCallback, MutableRefObject, 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 ContainerRef = RefObject<HTMLElement>;
type ItemRefs = MutableRefObject<Record<string, HTMLDivElement>>;
type OnMakeIndexVisible = (i: number)=> void;

const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible) => {
const useFocusNote = (containerRef: ContainerRef, itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible) => {
const focusItemIID = useRef(null);

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
Expand All @@ -25,6 +27,9 @@ const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisi
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
// So that keyboard events can still be handled, we need to ensure that some part
// of the note list keeps focus.
focus('useFocusNote0', containerRef.current);

if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focusItemIID.current = shim.setInterval(() => {
Expand All @@ -38,7 +43,7 @@ const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisi
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focus('useFocusNote2', itemRefs.current[noteId]);
}
}, [itemRefs, makeItemIndexVisible]);
}, [containerRef, itemRefs, makeItemIndexVisible]);

return focusNote;
};
Expand Down
37 changes: 30 additions & 7 deletions packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,41 @@ 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 isDownwardKey = [ 'ArrowDown', 'ArrowRight', 'PageDown', 'End' ].includes(key);
// let noteIndex = 0;
// for (let i = 0; i < notes.length; i++) {
// const index = isDownwardKey ? notes.length - 1 - i : i;
// if (selectedNoteIds.includes(notes[index].id)) {
// noteIndex = index;
// break;
// }
// }

const noteId = selectedNoteIds[selectedNoteIds.length - 1] ?? 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);

Expand Down
5 changes: 4 additions & 1 deletion packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface NoteItemProps {
note: NoteEntity;
isSelected: boolean;
isWatched: boolean;
tabIndex: number;
listRenderer: ListRenderer;
columns: NoteListColumns;
dispatch: Dispatch;
Expand Down Expand Up @@ -147,13 +148,15 @@ 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}

role='gridcell'
>
<div className="dragcursor" style={dragCursorStyle}></div>
</div>;
Expand Down

0 comments on commit 8c25ff1

Please sign in to comment.