= props => {
className='items'
ref={itemListRef}
style={listStyle}
+
items={listItems}
itemRenderer={onRenderItem}
- onKeyDown={onKeyEventHandler}
+ renderContentWrapper={onRenderContentWrapper}
+
+ // The selected item is the only item with tabindex=0. Always render it
+ // to allow the item list to be focused.
+ alwaysRenderSelection={true}
+ selectedIndex={selectedIndex}
itemHeight={30}
/>
diff --git a/packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts b/packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
index 9fad738dc..4f14ed027 100644
--- a/packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
+++ b/packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
@@ -1,29 +1,16 @@
-import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
+import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { ListItem } from '../types';
import ItemList from '../../ItemList';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
itemListRef: RefObject
>;
- selectedListElement: HTMLElement|null;
selectedIndex: number;
listItems: ListItem[];
}
-const useFocusAfterNextRenderHandler = (
- shouldFocusAfterNextRender: MutableRefObject,
- selectedListElement: HTMLElement|null,
-) => {
- useEffect(() => {
- if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
- focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
- shouldFocusAfterNextRender.current = false;
- }, [selectedListElement, shouldFocusAfterNextRender]);
-};
-
-const useRefocusOnSelectionChangeHandler = (
+const useScrollToSelectionHandler = (
itemListRef: RefObject>,
- shouldFocusAfterNextRender: MutableRefObject,
listItems: ListItem[],
selectedIndex: number,
) => {
@@ -49,32 +36,33 @@ const useRefocusOnSelectionChangeHandler = (
useEffect(() => {
if (!itemListRef.current || !selectedItemKey) return;
- const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus');
- shouldFocusAfterNextRender.current = hasFocus;
+ const hasFocus = !!itemListRef.current.container.contains(document.activeElement);
if (hasFocus) {
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
}
- }, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]);
+ }, [selectedItemKey, itemListRef]);
};
const useFocusHandler = (props: Props) => {
- const { itemListRef, selectedListElement, selectedIndex, listItems } = props;
+ const { itemListRef, selectedIndex, listItems } = props;
- // When set to true, when selectedListElement next changes, select it.
- const shouldFocusAfterNextRender = useRef(false);
-
- useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
- useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
+ useScrollToSelectionHandler(itemListRef, listItems, selectedIndex);
const focusSidebar = useCallback(() => {
- if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) {
+ if (!itemListRef.current.isIndexVisible(selectedIndex)) {
itemListRef.current.makeItemIndexVisible(selectedIndex);
- shouldFocusAfterNextRender.current = true;
- } else {
- focus('FolderAndTagList/useFocusHandler/focusSidebar', selectedListElement);
}
- }, [selectedListElement, selectedIndex, itemListRef]);
+
+ const focusableItem = itemListRef.current.container.querySelector('[role="treeitem"][tabindex="0"]');
+ const focusableContainer = itemListRef.current.container.querySelector('[role="tree"][tabindex="0"]');
+ if (focusableItem) {
+ focus('FolderAndTagList/focusSidebarItem', focusableItem);
+ } else if (focusableContainer) {
+ // Handles the case where no items in the tree can be focused.
+ focus('FolderAndTagList/focusSidebarTree', focusableContainer);
+ }
+ }, [selectedIndex, itemListRef]);
return { focusSidebar };
};
diff --git a/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
index aad5bf709..9dc34e967 100644
--- a/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
+++ b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
@@ -29,6 +29,8 @@ import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem';
+import ListItemWrapper from '../listItemComponents/ListItemWrapper';
+import { focus } from '@joplin/lib/utils/focusHandler';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -41,15 +43,27 @@ interface Props {
plugins: PluginStates;
folders: FolderEntity[];
collapsedFolderIds: string[];
+ containerRef: React.RefObject;
selectedIndex: number;
- onSelectedElementShown: (element: HTMLElement)=> void;
+ listItems: ListItem[];
}
type ItemContextMenuListener = MouseEventHandler;
const menuUtils = new MenuUtils(CommandService.instance());
+const focusListItem = (item: HTMLElement|null) => {
+ if (item) {
+ // Avoid scrolling to the selected item when refocusing the note list. Such a refocus
+ // can happen if the note list rerenders and the selection is scrolled out of view and
+ // can cause scroll to change unexpectedly.
+ focus('useOnRenderItem', item, { preventScroll: true });
+ }
+};
+
+const noFocusListItem = () => {};
+
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef(null);
@@ -326,26 +340,24 @@ const useOnRenderItem = (props: Props) => {
const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
+ const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index;
- const anchorRefCallback = selected ? (
- (element: HTMLElement) => {
- if (selectedIndexRef.current === index) {
- props.onSelectedElementShown(element);
- }
- }
- ) : null;
+ const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
+ const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return ;
} else if (item.kind === ListItemType.Folder) {
const folder = item.folder;
@@ -368,7 +380,7 @@ const useOnRenderItem = (props: Props) => {
}
return {
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
+ index={index}
+ itemCount={itemCount}
/>;
} else if (item.kind === ListItemType.Header) {
return ;
} else if (item.kind === ListItemType.AllNotes) {
return ;
} else if (item.kind === ListItemType.Spacer) {
return (
-
+
+
+
);
} else {
const exhaustivenessCheck: never = item;
@@ -421,7 +451,8 @@ const useOnRenderItem = (props: Props) => {
showFolderIcons,
tagItem_click,
props.selectedIndex,
- props.onSelectedElementShown,
+ props.containerRef,
+ itemCount,
]);
};
diff --git a/packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.tsx b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.tsx
new file mode 100644
index 000000000..af0140d1d
--- /dev/null
+++ b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { useCallback } from 'react';
+import { _ } from '@joplin/lib/locale';
+import CommandService from '@joplin/lib/services/CommandService';
+
+interface Props {
+ selectedIndex: number;
+ onKeyDown: React.KeyboardEventHandler;
+}
+
+const onAddFolderButtonClick = () => {
+ void CommandService.instance().execute('newFolder');
+};
+
+const NewFolderButton = () => {
+ // To allow it to be accessed by accessibility tools, the new folder button
+ // is not included in the portion of the list with role='tree'.
+ return ;
+};
+
+const useOnRenderListWrapper = ({ selectedIndex, onKeyDown }: Props) => {
+ return useCallback((listItems: React.ReactNode[]) => {
+ const listHasValidSelection = selectedIndex >= 0;
+ const allowContainerFocus = !listHasValidSelection;
+ return <>
+
+
+ {...listItems}
+
+ >;
+ }, [selectedIndex, onKeyDown]);
+};
+
+export default useOnRenderListWrapper;
diff --git a/packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.ts b/packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.ts
index acd335c3e..b0c85897f 100644
--- a/packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.ts
+++ b/packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.ts
@@ -1,7 +1,8 @@
import { Dispatch } from 'redux';
-import { FolderListItem, ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
+import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
+import toggleHeader from './utils/toggleHeader';
interface Props {
dispatch: Dispatch;
@@ -12,15 +13,20 @@ interface Props {
}
-const isToggleShortcut = (keyCode: string, selectedItem: FolderListItem, collapsedFolderIds: string[]) => {
+const isToggleShortcut = (keyCode: string, selectedItem: ListItem, collapsedFolderIds: string[]) => {
+ if (selectedItem.kind !== ListItemType.Header && selectedItem.kind !== ListItemType.Folder) {
+ return false;
+ }
+
if (!['Space', 'ArrowLeft', 'ArrowRight'].includes(keyCode)) {
return false;
}
+
if (keyCode === 'Space') {
return true;
}
- const isCollapsed = collapsedFolderIds.includes(selectedItem.folder.id);
+ const isCollapsed = 'expanded' in selectedItem ? !selectedItem.expanded : collapsedFolderIds.includes(selectedItem.folder.id);
return (keyCode === 'ArrowRight') === isCollapsed;
};
@@ -29,21 +35,22 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
return useCallback>((event) => {
const selectedItem = listItems[selectedIndex];
- if (selectedItem?.kind === ListItemType.Folder && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
- event.preventDefault();
-
- dispatch({
- type: 'FOLDER_TOGGLE',
- id: selectedItem.folder.id,
- });
- }
-
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
- event.preventDefault();
- }
-
let indexChange = 0;
- if (event.code === 'ArrowUp') {
+
+ if (selectedItem && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
+ event.preventDefault();
+
+ if (selectedItem.kind === ListItemType.Folder) {
+ dispatch({
+ type: 'FOLDER_TOGGLE',
+ id: selectedItem.folder.id,
+ });
+ } else if (selectedItem.kind === ListItemType.Header) {
+ toggleHeader(selectedItem.id);
+ }
+ } else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
+ event.preventDefault();
+ } else if (event.code === 'ArrowUp') {
indexChange = -1;
} else if (event.code === 'ArrowDown') {
indexChange = 1;
diff --git a/packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts b/packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
index f110722dd..e5f05f311 100644
--- a/packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
+++ b/packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
@@ -3,8 +3,7 @@ import { FolderListItem, HeaderId, HeaderListItem, ListItem, ListItemType, TagLi
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
-import CommandService from '@joplin/lib/services/CommandService';
-import Setting from '@joplin/lib/models/Setting';
+import toggleHeader from './utils/toggleHeader';
interface Props {
tags: TagsWithNoteCountEntity[];
@@ -14,16 +13,6 @@ interface Props {
tagHeaderIsExpanded: boolean;
}
-const onAddFolderButtonClick = () => {
- void CommandService.instance().execute('newFolder');
-};
-
-const onHeaderClick = (headerId: HeaderId) => {
- const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
- const current = Setting.value(settingKey);
- Setting.setValue(settingKey, !current);
-};
-
const useSidebarListData = (props: Props): ListItem[] => {
const tagItems = useMemo(() => {
return renderTags(props.tags, (tag): TagListItem => {
@@ -60,10 +49,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header,
label: _('Notebooks'),
iconName: 'icon-notebooks',
+ expanded: props.folderHeaderIsExpanded,
id: HeaderId.FolderHeader,
key: HeaderId.FolderHeader,
- onClick: onHeaderClick,
- onPlusButtonClick: onAddFolderButtonClick,
+ onClick: toggleHeader,
extraProps: {
['data-folder-id']: '',
},
@@ -79,10 +68,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header,
label: _('Tags'),
iconName: 'icon-tags',
+ expanded: props.tagHeaderIsExpanded,
id: HeaderId.TagHeader,
key: HeaderId.TagHeader,
- onClick: onHeaderClick,
- onPlusButtonClick: null,
+ onClick: toggleHeader,
extraProps: { },
supportsFolderDrop: false,
};
diff --git a/packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.ts b/packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.ts
new file mode 100644
index 000000000..719d2704d
--- /dev/null
+++ b/packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.ts
@@ -0,0 +1,10 @@
+import Setting from '@joplin/lib/models/Setting';
+import { HeaderId } from '../../types';
+
+const toggleHeader = (headerId: HeaderId) => {
+ const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
+ const current = Setting.value(settingKey);
+ Setting.setValue(settingKey, !current);
+};
+
+export default toggleHeader;
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx
index 70a5e5130..2bfaf2c68 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles';
+import { StyledAllNotesIcon, StyledListItemAnchor } from '../styles';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
@@ -10,6 +10,7 @@ import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSort
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
+import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const Menu = bridge().Menu;
@@ -17,8 +18,10 @@ const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
+ anchorRef: ListItemRef;
selected: boolean;
- anchorRef: React.Ref;
+ index: number;
+ itemCount: number;
}
const menuUtils = new MenuUtils(CommandService.instance());
@@ -46,21 +49,28 @@ const AllNotesItem: React.FC = props => {
}, []);
return (
-
+
-
+
{_('All notes')}
-
+
);
};
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.tsx
index ea47ca9d8..79f4d9523 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.tsx
@@ -2,10 +2,11 @@ import * as React from 'react';
import ExpandIcon from './ExpandIcon';
interface Props {
+ className?: string;
}
-const EmptyExpandLink: React.FC = _props => {
- return ;
+const EmptyExpandLink: React.FC = props => {
+ return ;
};
export default EmptyExpandLink;
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx
index d9ba70f1c..72a5235a9 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx
@@ -23,11 +23,12 @@ const ExpandIcon: React.FC = props => {
return undefined;
}
if (props.isExpanded) {
- return _('Collapse %s', props.targetTitle);
+ return _('Expanded, press space to collapse.');
}
- return _('Expand %s', props.targetTitle);
+ return _('Collapsed, press space to expand.');
};
- return ;
+ const label = getLabel();
+ return ;
};
export default ExpandIcon;
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.tsx
index 74d1d02ea..2828fd470 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.tsx
@@ -8,16 +8,17 @@ interface ExpandLinkProps {
folderTitle: string;
hasChildren: boolean;
isExpanded: boolean;
+ className: string;
onClick: MouseEventHandler;
}
const ExpandLink: React.FC = props => {
return props.hasChildren ? (
-
+
) : (
-
+
);
};
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.tsx
index dd7af6140..f2ffc77ba 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import ExpandLink from './ExpandLink';
-import { StyledListItem, StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles';
+import { StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles';
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
import FolderIconBox from '../../FolderIconBox';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
@@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount';
+import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
@@ -26,6 +27,7 @@ const renderFolderIcon = (folderIcon: FolderIcon) => {
};
interface FolderItemProps {
+ anchorRef: ListItemRef;
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
@@ -43,7 +45,9 @@ interface FolderItemProps {
onFolderToggleClick_: ItemClickListener;
shareId: string;
selected: boolean;
- anchorRef: React.Ref;
+
+ index: number;
+ itemCount: number;
}
function FolderItem(props: FolderItemProps) {
@@ -63,29 +67,50 @@ function FolderItem(props: FolderItemProps) {
};
return (
-
-
+
{
folderItem_click(folderId);
}}
- onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}{folderTitle}
{shareIcon}
-
+
+
);
}
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx
index 357a17a7e..08080961c 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx
@@ -1,12 +1,11 @@
import * as React from 'react';
import { useCallback } from 'react';
-import { ButtonLevel } from '../../Button/Button';
-import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
+import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types';
-import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
+import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -14,9 +13,12 @@ const menuUtils = new MenuUtils(CommandService.instance());
interface Props {
+ anchorRef: ListItemRef;
item: HeaderListItem;
+ isSelected: boolean;
onDrop: React.DragEventHandler|null;
- anchorRef: React.Ref;
+ index: number;
+ itemCount: number;
}
const HeaderItem: React.FC = props => {
@@ -42,30 +44,25 @@ const HeaderItem: React.FC = props => {
}
}, [itemId]);
- const addButton = ;
-
return (
-
-
-
+
+
{item.label}
- { item.onPlusButtonClick && addButton }
-
+
);
};
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/ListItemWrapper.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/ListItemWrapper.tsx
new file mode 100644
index 000000000..89e246afe
--- /dev/null
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/ListItemWrapper.tsx
@@ -0,0 +1,66 @@
+import { ModelType } from '@joplin/lib/BaseModel';
+import * as React from 'react';
+import { useMemo } from 'react';
+
+export type ListItemRef = React.Ref;
+
+interface Props {
+ containerRef: ListItemRef;
+ selected: boolean;
+ itemIndex: number;
+ itemCount: number;
+ expanded?: boolean|undefined;
+ depth: number;
+ className?: string;
+ highlightOnHover: boolean;
+ children: (React.ReactNode[])|React.ReactNode;
+
+ onContextMenu?: React.MouseEventHandler;
+ onDrag?: React.DragEventHandler;
+ onDragStart?: React.DragEventHandler;
+ onDragOver?: React.DragEventHandler;
+ onDrop?: React.DragEventHandler;
+ draggable?: boolean;
+ 'data-folder-id'?: string;
+ 'data-id'?: string;
+ 'data-type'?: ModelType;
+}
+
+const ListItemWrapper: React.FC = props => {
+ const style = useMemo(() => {
+ return {
+ '--depth': props.depth,
+ } as React.CSSProperties;
+ }, [props.depth]);
+
+ return (
+
+ {props.children}
+
+ );
+};
+
+export default ListItemWrapper;
diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.tsx
index 6730173f0..15bfe8b03 100644
--- a/packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.tsx
+++ b/packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.tsx
@@ -1,22 +1,26 @@
import Setting from '@joplin/lib/models/Setting';
import * as React from 'react';
import { useCallback } from 'react';
-import { StyledListItem, StyledListItemAnchor, StyledSpanFix } from '../styles';
+import { StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import Tag from '@joplin/lib/models/Tag';
import EmptyExpandLink from './EmptyExpandLink';
+import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
interface Props {
+ anchorRef: ListItemRef;
selected: boolean;
- anchorRef: React.Ref;
tag: TagsWithNoteCountEntity;
onTagDrop: React.DragEventHandler;
onContextMenu: React.MouseEventHandler;
onClick: (event: TagLinkClickEvent)=> void;
+
+ itemCount: number;
+ index: number;
}
const TagItem = (props: Props) => {
@@ -33,18 +37,21 @@ const TagItem = (props: Props) => {
}, [props.onClick, tag]);
return (
-
{
{Tag.displayTitle(tag)}
{noteCount}
-
+
);
};
diff --git a/packages/app-desktop/gui/Sidebar/style.scss b/packages/app-desktop/gui/Sidebar/style.scss
index 59247115d..f1598f188 100644
--- a/packages/app-desktop/gui/Sidebar/style.scss
+++ b/packages/app-desktop/gui/Sidebar/style.scss
@@ -1,6 +1,8 @@
@use 'styles/folder-and-tag-list.scss';
+@use 'styles/list-item-wrapper.scss';
@use 'styles/note-count-label.scss';
@use 'styles/sidebar-expand-icon.scss';
@use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss';
-@use 'styles/sidebar-spacer-item.scss';
\ No newline at end of file
+@use 'styles/sidebar-spacer-item.scss';
+@use 'styles/new-folder-button.scss';
\ No newline at end of file
diff --git a/packages/app-desktop/gui/Sidebar/styles/index.ts b/packages/app-desktop/gui/Sidebar/styles/index.ts
index 37ba9fa9e..ff59ce8a4 100644
--- a/packages/app-desktop/gui/Sidebar/styles/index.ts
+++ b/packages/app-desktop/gui/Sidebar/styles/index.ts
@@ -49,22 +49,6 @@ export const StyledHeaderLabel = styled.span`
font-weight: bold;
`;
-export const StyledListItem = styled.div`
- box-sizing: border-box;
- height: 30px;
- display: flex;
- flex-direction: row;
- align-items: center;
- padding-left: ${(props: StyleProps) => props.theme.mainPadding + ('depth' in props ? props.depth : 0) * 16}px;
- background: ${(props: StyleProps) => props.selected ? props.theme.selectedColor2 : 'none'};
- /*text-transform: ${(props: StyleProps) => props.isSpecialItem ? 'uppercase' : 'none'};*/
- transition: 0.1s;
-
- &:hover {
- background-color: ${(props: StyleProps) => props.theme.backgroundColorHover2};
- }
-`;
-
function listItemTextColor(props: StyleProps) {
if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2;
diff --git a/packages/app-desktop/gui/Sidebar/styles/list-item-wrapper.scss b/packages/app-desktop/gui/Sidebar/styles/list-item-wrapper.scss
new file mode 100644
index 000000000..a742fc935
--- /dev/null
+++ b/packages/app-desktop/gui/Sidebar/styles/list-item-wrapper.scss
@@ -0,0 +1,25 @@
+
+.list-item-wrapper {
+ box-sizing: border-box;
+ height: 30px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding-left: calc(var(--joplin-main-padding) + (var(--depth) * 16px) - 16px);
+ background: none;
+ transition: 0.1s;
+
+ // Show the toggle button first, even if it's markup is included later for a better screen reader
+ // experience.
+ > .toggle {
+ order: -1;
+ }
+
+ &.-selected {
+ background: var(--joplin-selected-color2);
+ }
+
+ &.-highlight-on-hover:hover {
+ background-color: var(--joplin-background-color-hover2);
+ }
+}
\ No newline at end of file
diff --git a/packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss b/packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss
new file mode 100644
index 000000000..2bfcb379d
--- /dev/null
+++ b/packages/app-desktop/gui/Sidebar/styles/new-folder-button.scss
@@ -0,0 +1,25 @@
+
+.new-folder-button {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+
+ padding-inline-end: 15px;
+ padding-top: 4px;
+ height: 30px;
+ border: none;
+
+ background-color: transparent;
+ font-size: var(--joplin-toolbar-icon-size);
+ color: var(--joplin-color2);
+
+ &:hover {
+ color: var(--joplin-color-hover2);
+ background: none;
+ }
+
+ &:active {
+ color: var(--joplin-color-active2);
+ background: none;
+ }
+}
diff --git a/packages/app-desktop/gui/Sidebar/styles/sidebar-expand-link.scss b/packages/app-desktop/gui/Sidebar/styles/sidebar-expand-link.scss
index fa40ebaee..45f0ec18f 100644
--- a/packages/app-desktop/gui/Sidebar/styles/sidebar-expand-link.scss
+++ b/packages/app-desktop/gui/Sidebar/styles/sidebar-expand-link.scss
@@ -5,6 +5,7 @@
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
+ text-align: center;
display: flex;
align-items: center;
width: 16px;
diff --git a/packages/app-desktop/gui/Sidebar/types.ts b/packages/app-desktop/gui/Sidebar/types.ts
index 59fc7de04..4eb9b7202 100644
--- a/packages/app-desktop/gui/Sidebar/types.ts
+++ b/packages/app-desktop/gui/Sidebar/types.ts
@@ -21,10 +21,10 @@ interface BaseListItem {
export interface HeaderListItem extends BaseListItem {
kind: ListItemType.Header;
label: string;
+ expanded: boolean;
iconName: string;
id: HeaderId;
onClick: ((headerId: HeaderId, event: ReactMouseEvent)=> void)|null;
- onPlusButtonClick: MouseEventHandler|null;
extraProps: Record;
supportsFolderDrop: boolean;
}
diff --git a/packages/app-desktop/integration-tests/models/Sidebar.ts b/packages/app-desktop/integration-tests/models/Sidebar.ts
index d2c13b111..1a35d2a4b 100644
--- a/packages/app-desktop/integration-tests/models/Sidebar.ts
+++ b/packages/app-desktop/integration-tests/models/Sidebar.ts
@@ -19,7 +19,7 @@ export default class Sidebar {
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
await submitButton.click();
- return this.container.getByText(title);
+ return this.container.getByRole('treeitem', { name: title });
}
private async sortBy(electronApp: ElectronApplication, option: string) {
diff --git a/packages/app-desktop/integration-tests/sidebar.spec.ts b/packages/app-desktop/integration-tests/sidebar.spec.ts
index 94d293783..5835abb69 100644
--- a/packages/app-desktop/integration-tests/sidebar.spec.ts
+++ b/packages/app-desktop/integration-tests/sidebar.spec.ts
@@ -56,24 +56,24 @@ test.describe('sidebar', () => {
await sidebar.forceUpdateSorting(electronApp);
+ await expect(childFolderHeader).toBeVisible();
await childFolderHeader.dragTo(parentFolderHeader);
// Verify that it's now a child folder -- expand and collapse the parent
- const collapseButton = sidebar.container.getByRole('link', { name: 'Collapse Parent folder' });
- await expect(collapseButton).toBeVisible();
- await collapseButton.click();
+ await expect(parentFolderHeader).toHaveJSProperty('ariaExpanded', 'true');
+ const toggleButton = parentFolderHeader.getByRole('button', { name: /^(Expand|Collapse)/ });
+ await toggleButton.click();
// Should be collapsed
await expect(childFolderHeader).not.toBeAttached();
+ await expect(parentFolderHeader).toHaveJSProperty('ariaExpanded', 'false');
- const expandButton = sidebar.container.getByRole('link', { name: 'Expand Parent folder' });
- await expandButton.click();
+ await toggleButton.click();
// Should be possible to move back to the root
const rootFolderHeader = sidebar.container.getByText('Notebooks');
await childFolderHeader.dragTo(rootFolderHeader);
- await expect(collapseButton).not.toBeVisible();
- await expect(expandButton).not.toBeVisible();
+ await expect(toggleButton).not.toBeVisible();
});
test('all notes section should list all notes', async ({ electronApp, mainWindow }) => {
diff --git a/packages/lib/utils/focusHandler.ts b/packages/lib/utils/focusHandler.ts
index be7ab5599..af38c0b2e 100644
--- a/packages/lib/utils/focusHandler.ts
+++ b/packages/lib/utils/focusHandler.ts
@@ -12,12 +12,16 @@ enum ToggleFocusAction {
Blur = 'blur',
}
+interface FocusOptions {
+ preventScroll: boolean;
+}
+
interface FocusableElement {
- focus: ()=> void;
+ focus: (options?: FocusOptions)=> void;
blur: ()=> void;
}
-const toggleFocus = (source: string, element: FocusableElement, action: ToggleFocusAction) => {
+const toggleFocus = (source: string, element: FocusableElement, action: ToggleFocusAction, options: FocusOptions|null) => {
if (!element) {
logger.warn(`Tried action "${action}" on an undefined element: ${source}`);
return;
@@ -29,15 +33,19 @@ const toggleFocus = (source: string, element: FocusableElement, action: ToggleFo
}
logger.debug(`Action "${action}" from "${source}"`);
- element[action]();
+ if (options) {
+ element[action](options);
+ } else {
+ element[action]();
+ }
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
-export const focus = (source: string, element: any) => {
- toggleFocus(source, element, ToggleFocusAction.Focus);
+export const focus = (source: string, element: any, options: FocusOptions|null = null) => {
+ toggleFocus(source, element, ToggleFocusAction.Focus, options);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const blur = (source: string, element: any) => {
- toggleFocus(source, element, ToggleFocusAction.Blur);
+ toggleFocus(source, element, ToggleFocusAction.Blur, null);
};
diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt
index e67f10bb0..ae72b46fd 100644
--- a/packages/tools/cspell/dictionary4.txt
+++ b/packages/tools/cspell/dictionary4.txt
@@ -132,3 +132,4 @@ Famegear
rcompare
tabindex
Backblaze
+treeitem