diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts index a6fbec02e83..12a986df333 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptions.ts @@ -19,7 +19,10 @@ export interface NotesAndFilesDisplayOptions extends GenericDisplayOptions { customFilter?: DisplayControllerCustomFilter } -export type TagsDisplayOptions = GenericDisplayOptions +export interface TagsAndViewsDisplayOptions extends GenericDisplayOptions { + searchQuery?: SearchQuery + customFilter?: DisplayControllerCustomFilter +} export interface DisplayControllerDisplayOptions extends GenericDisplayOptions { sortBy: CollectionSortProperty @@ -27,5 +30,5 @@ export interface DisplayControllerDisplayOptions extends GenericDisplayOptions { } export type NotesAndFilesDisplayControllerOptions = NotesAndFilesDisplayOptions & DisplayControllerDisplayOptions -export type TagsDisplayControllerOptions = TagsDisplayOptions & DisplayControllerDisplayOptions -export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsDisplayOptions | GenericDisplayOptions +export type TagsDisplayControllerOptions = TagsAndViewsDisplayOptions & DisplayControllerDisplayOptions +export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsAndViewsDisplayOptions | GenericDisplayOptions diff --git a/packages/services/src/Domain/Item/ItemManagerInterface.ts b/packages/services/src/Domain/Item/ItemManagerInterface.ts index 2715cd99366..c8433786291 100644 --- a/packages/services/src/Domain/Item/ItemManagerInterface.ts +++ b/packages/services/src/Domain/Item/ItemManagerInterface.ts @@ -21,6 +21,7 @@ import { NotesAndFilesDisplayControllerOptions, ComponentInterface, ItemStream, + TagsAndViewsDisplayOptions, } from '@standardnotes/models' import { AbstractService } from '../Service/AbstractService' @@ -130,6 +131,7 @@ export interface ItemManagerInterface extends AbstractService { getDisplayableNotes(): SNNote[] getDisplayableNotesAndFiles(): (SNNote | FileItem)[] setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void + setTagsAndViewsDisplayOptions(options: TagsAndViewsDisplayOptions): void getTagPrefixTitle(tag: SNTag): string | undefined getItemLinkedFiles(item: DecryptedItemInterface): FileItem[] getItemLinkedNotes(item: DecryptedItemInterface): SNNote[] diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index 22684c01349..ce3cbfa2a69 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -34,12 +34,12 @@ export class ItemManager extends Services.AbstractService implements Services.It Models.SNNote | Models.FileItem, Models.NotesAndFilesDisplayOptions > - private tagDisplayController!: Models.ItemDisplayController + private tagDisplayController!: Models.ItemDisplayController private itemsKeyDisplayController!: Models.ItemDisplayController private componentDisplayController!: Models.ItemDisplayController private themeDisplayController!: Models.ItemDisplayController private fileDisplayController!: Models.ItemDisplayController - private smartViewDisplayController!: Models.ItemDisplayController + private smartViewDisplayController!: Models.ItemDisplayController constructor( private payloadManager: PayloadManager, @@ -73,10 +73,14 @@ export class ItemManager extends Services.AbstractService implements Services.It hiddenContentTypes: [], }, ) - this.tagDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.Tag], { - sortBy: 'title', - sortDirection: 'asc', - }) + this.tagDisplayController = new Models.ItemDisplayController( + this.collection, + [ContentType.TYPES.Tag], + { + sortBy: 'title', + sortDirection: 'asc', + }, + ) this.itemsKeyDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.ItemsKey], { sortBy: 'created_at', sortDirection: 'asc', @@ -89,7 +93,10 @@ export class ItemManager extends Services.AbstractService implements Services.It sortBy: 'title', sortDirection: 'asc', }) - this.smartViewDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.SmartView], { + this.smartViewDisplayController = new Models.ItemDisplayController< + Models.SmartView, + Models.TagsAndViewsDisplayOptions + >(this.collection, [ContentType.TYPES.SmartView], { sortBy: 'title', sortDirection: 'asc', }) @@ -194,6 +201,16 @@ export class ItemManager extends Services.AbstractService implements Services.It this.itemCounter.setDisplayOptions(updatedOptions) } + public setTagsAndViewsDisplayOptions(options: Models.TagsAndViewsDisplayOptions): void { + const updatedOptions: Models.TagsAndViewsDisplayOptions = { + customFilter: Models.computeUnifiedFilterForDisplayOptions(options, this.collection), + ...options, + } + + this.tagDisplayController.setDisplayOptions(updatedOptions) + this.smartViewDisplayController.setDisplayOptions(updatedOptions) + } + public setVaultDisplayOptions(options: Models.VaultDisplayOptions): void { this.navigationDisplayController.setVaultDisplayOptions(options) this.tagDisplayController.setVaultDisplayOptions(options) diff --git a/packages/web/src/javascripts/Components/Menu/Menu.tsx b/packages/web/src/javascripts/Components/Menu/Menu.tsx index be1c3945c80..d008f16ad25 100644 --- a/packages/web/src/javascripts/Components/Menu/Menu.tsx +++ b/packages/web/src/javascripts/Components/Menu/Menu.tsx @@ -4,7 +4,7 @@ import { KeyboardEventHandler, useCallback, useImperativeHandle, - useRef, + useState, } from 'react' import { KeyboardKey } from '@standardnotes/ui-services' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' @@ -33,7 +33,7 @@ const Menu = forwardRef( }: MenuProps, forwardedRef, ) => { - const menuElementRef = useRef(null) + const [menuElement, setMenuElement] = useState(null) const handleKeyDown: KeyboardEventHandler = useCallback( (event) => { @@ -49,11 +49,10 @@ const Menu = forwardRef( const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) - const { setInitialFocus } = useListKeyboardNavigation( - menuElementRef, + const { setInitialFocus } = useListKeyboardNavigation(menuElement, { initialFocus, - isMobileScreen ? false : shouldAutoFocus, - ) + shouldAutoFocus: isMobileScreen ? false : shouldAutoFocus, + }) useImperativeHandle(forwardedRef, () => ({ focus: () => { @@ -65,7 +64,7 @@ const Menu = forwardRef( (null) - useListKeyboardNavigation(listRef) + const [listElement, setListElement] = useState(null) + useListKeyboardNavigation(listElement) const [selectedMobileTab, setSelectedMobileTab] = useState<'list' | 'preview'>('list') @@ -279,7 +279,7 @@ const NoteConflictResolutionModal = ({ 'w-full overflow-y-auto border-r border-border py-1.5 md:flex md:w-auto md:min-w-60 md:flex-col', selectedMobileTab !== 'list' && 'hidden md:flex', )} - ref={listRef} + ref={setListElement} > {allVersions.map((note, index) => ( = ({ legacyHistory, noteHistoryController, onSelectRevision }) => { const { selectLegacyRevision, selectedEntry } = noteHistoryController - const legacyHistoryListRef = useRef(null) + const [listElement, setListElement] = useState(null) - useListKeyboardNavigation(legacyHistoryListRef) + useListKeyboardNavigation(listElement) return (
{legacyHistory?.map((entry) => { const selectedEntryUrl = (selectedEntry as Action)?.subactions?.[0].url diff --git a/packages/web/src/javascripts/Components/RevisionHistoryModal/RemoteHistoryList.tsx b/packages/web/src/javascripts/Components/RevisionHistoryModal/RemoteHistoryList.tsx index cf08883b4fc..f3a419bee3d 100644 --- a/packages/web/src/javascripts/Components/RevisionHistoryModal/RemoteHistoryList.tsx +++ b/packages/web/src/javascripts/Components/RevisionHistoryModal/RemoteHistoryList.tsx @@ -1,5 +1,5 @@ import { observer } from 'mobx-react-lite' -import { Fragment, FunctionComponent, useMemo, useRef } from 'react' +import { Fragment, FunctionComponent, useMemo, useState } from 'react' import Icon from '@/Components/Icon/Icon' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import HistoryListItem from './HistoryListItem' @@ -22,9 +22,9 @@ const RemoteHistoryList: FunctionComponent = ({ }) => { const { remoteHistory, isFetchingRemoteHistory, selectRemoteRevision, selectedEntry } = noteHistoryController - const remoteHistoryListRef = useRef(null) + const [listElement, setListElement] = useState(null) - useListKeyboardNavigation(remoteHistoryListRef) + useListKeyboardNavigation(listElement) const remoteHistoryLength = useMemo(() => remoteHistory?.map((group) => group.entries).flat().length, [remoteHistory]) @@ -33,7 +33,7 @@ const RemoteHistoryList: FunctionComponent = ({ className={`flex h-full w-full flex-col focus:shadow-none ${ isFetchingRemoteHistory || !remoteHistoryLength ? 'items-center justify-center' : '' }`} - ref={remoteHistoryListRef} + ref={setListElement} > {isFetchingRemoteHistory && } {remoteHistory?.map((group) => { diff --git a/packages/web/src/javascripts/Components/RevisionHistoryModal/SessionHistoryList.tsx b/packages/web/src/javascripts/Components/RevisionHistoryModal/SessionHistoryList.tsx index 5f21eebfb57..3dcebb16808 100644 --- a/packages/web/src/javascripts/Components/RevisionHistoryModal/SessionHistoryList.tsx +++ b/packages/web/src/javascripts/Components/RevisionHistoryModal/SessionHistoryList.tsx @@ -1,4 +1,4 @@ -import { Fragment, FunctionComponent, useMemo, useRef } from 'react' +import { Fragment, FunctionComponent, useMemo, useState } from 'react' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import HistoryListItem from './HistoryListItem' import { observer } from 'mobx-react-lite' @@ -12,9 +12,9 @@ type Props = { const SessionHistoryList: FunctionComponent = ({ noteHistoryController, onSelectRevision }) => { const { sessionHistory, selectedRevision, selectSessionRevision } = noteHistoryController - const sessionHistoryListRef = useRef(null) + const [listElement, setListElement] = useState(null) - useListKeyboardNavigation(sessionHistoryListRef) + useListKeyboardNavigation(listElement) const sessionHistoryLength = useMemo( () => sessionHistory?.map((group) => group.entries).flat().length, @@ -26,7 +26,7 @@ const SessionHistoryList: FunctionComponent = ({ noteHistoryController, o className={`flex h-full w-full flex-col focus:shadow-none ${ !sessionHistoryLength ? 'items-center justify-center' : '' }`} - ref={sessionHistoryListRef} + ref={setListElement} > {sessionHistory?.map((group) => { if (group.entries && group.entries.length) { diff --git a/packages/web/src/javascripts/Components/Tags/Navigation.tsx b/packages/web/src/javascripts/Components/Tags/Navigation.tsx index c6a6c9fe88e..53c5e51de83 100644 --- a/packages/web/src/javascripts/Components/Tags/Navigation.tsx +++ b/packages/web/src/javascripts/Components/Tags/Navigation.tsx @@ -17,6 +17,7 @@ import { useAvailableSafeAreaPadding } from '@/Hooks/useSafeAreaPadding' import QuickSettingsButton from '../Footer/QuickSettingsButton' import VaultSelectionButton from '../Footer/VaultSelectionButton' import PreferencesButton from '../Footer/PreferencesButton' +import TagSearchBar from './TagSearchBar' type Props = { application: WebApplication @@ -78,6 +79,7 @@ const Navigation = forwardRef(({ application, className, 'md:hover:[overflow-y:_overlay] pointer-coarse:md:overflow-y-auto', )} > + = ({ }: Props) => { const allViews = navigationController.smartViews + const [container, setContainer] = useState(null) + + useListKeyboardNavigation(container, { + initialFocus: 0, + shouldAutoFocus: false, + shouldWrapAround: false, + resetLastFocusedOnBlur: true, + }) + + if (allViews.length === 0 && navigationController.isSearching) { + return ( +
No smart views found. Try a different search.
+ ) + } + return ( - <> +
{allViews.map((view) => { return ( = ({ /> ) })} - +
) } diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx index 1a98f0d4f18..51f511a499e 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx @@ -111,74 +111,73 @@ const SmartViewsListItem: FunctionComponent = ({ view, tagsState, setEdit } return ( - <> -
{ - event.preventDefault() - event.stopPropagation() - if (isSystemView(view)) { - return - } - onClickEdit() - }} - style={{ - paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, - }} - > -
-
- -
- {isEditing ? ( - - ) : ( -
- {title} -
- )} -
- {view.uuid === SystemViewId.AllNotes && tagsState.allNotesCount} - {view.uuid === SystemViewId.Files && tagsState.allFilesCount} - {view.uuid === SystemViewId.Conflicts && conflictsCount} -
+ ) } diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx index 2c5aac3c71f..b99a7bc3daf 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx @@ -40,13 +40,15 @@ const SmartViewsSection: FunctionComponent = ({ application, navigationCo
Views
- + {!navigationController.isSearching && ( + + )}
{ + const { searchQuery, setSearchQuery } = navigationController + + const inputRef = useRef(null) + + const onClearSearch = useCallback(() => { + setSearchQuery('') + inputRef.current?.focus() + }, [setSearchQuery]) + + const [isParentScrolling, setIsParentScrolling] = useState(false) + const searchBarRef = useRef(null) + + useEffect(() => { + const searchBar = searchBarRef.current + if (!searchBar) { + return + } + + const parent = searchBar.parentElement + if (!parent) { + return + } + + const scrollListener = () => { + const { scrollTop } = parent + setIsParentScrolling(scrollTop > 0) + } + + parent.addEventListener('scroll', scrollListener) + + return () => { + parent.removeEventListener('scroll', scrollListener) + } + }, []) + + return ( +
+ ]} + right={[searchQuery && ]} + roundedFull + /> +
+ ) +} + +export default observer(TagSearchBar) diff --git a/packages/web/src/javascripts/Components/Tags/TagsList.tsx b/packages/web/src/javascripts/Components/Tags/TagsList.tsx index 906108e65ec..da69f7eb7bf 100644 --- a/packages/web/src/javascripts/Components/Tags/TagsList.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagsList.tsx @@ -1,10 +1,11 @@ import { SNTag } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useCallback } from 'react' +import { FunctionComponent, useCallback, useState } from 'react' import RootTagDropZone from './RootTagDropZone' import { TagListSectionType } from './TagListSection' import { TagsListItem } from './TagsListItem' import { useApplication } from '../ApplicationProvider' +import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' type Props = { type: TagListSectionType @@ -32,31 +33,44 @@ const TagsList: FunctionComponent = ({ type }: Props) => { [application, openTagContextMenu], ) + const [container, setContainer] = useState(null) + + useListKeyboardNavigation(container, { + initialFocus: 0, + shouldAutoFocus: false, + shouldWrapAround: false, + resetLastFocusedOnBlur: true, + }) + + if (allTags.length === 0) { + return ( +
+ {application.navigationController.isSearching + ? 'No tags found. Try a different search.' + : 'No tags or folders. Create one using the add button above.'} +
+ ) + } + return ( <> - {allTags.length === 0 ? ( -
- No tags or folders. Create one using the add button above. -
- ) : ( - <> - {allTags.map((tag) => { - return ( - - ) - })} - {type === 'all' && } - - )} +
+ {allTags.map((tag) => { + return ( + + ) + })} +
+ {type === 'all' && } ) } diff --git a/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx b/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx index 83c990da992..4992068715f 100644 --- a/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx @@ -91,11 +91,19 @@ export const TagsListItem: FunctionComponent = observer( e?.stopPropagation() const shouldShowChildren = !showChildren setShowChildren(shouldShowChildren) - navigationController.setExpanded(tag, shouldShowChildren) + if (!navigationController.isSearching) { + navigationController.setExpanded(tag, shouldShowChildren) + } }, [showChildren, tag, navigationController], ) + useEffect(() => { + if (!navigationController.isSearching) { + setShowChildren(tag.expanded) + } + }, [navigationController.isSearching, tag]) + const selectCurrentTag = useCallback(async () => { await navigationController.setSelectedTag(tag, type, { userTriggered: true, @@ -269,11 +277,16 @@ export const TagsListItem: FunctionComponent = observer( role="button" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} className={classNames( - 'tag group px-3.5 py-1 md:py-0', + 'tag group px-3.5 py-0.5 focus-visible:!shadow-inner md:py-0', (isSelected || isContextMenuOpenForTag) && 'selected', isBeingDraggedOver && 'is-drag-over', )} onClick={selectCurrentTag} + onKeyDown={(event) => { + if (event.key === KeyboardKey.Enter || event.key === KeyboardKey.Space) { + selectCurrentTag().catch(console.error) + } + }} ref={tagRef} style={{ paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, @@ -282,7 +295,7 @@ export const TagsListItem: FunctionComponent = observer( e.preventDefault() onContextMenu(tag, type, e.clientX, e.clientY) }} - draggable={true} + draggable={!navigationController.isSearching} onDragStart={onDragStart} onDragEnter={onDragEnter} onDragExit={removeDragIndicator} diff --git a/packages/web/src/javascripts/Components/Tags/TagsSection.tsx b/packages/web/src/javascripts/Components/Tags/TagsSection.tsx index 5ee9dcf58d5..01eab909f7b 100644 --- a/packages/web/src/javascripts/Components/Tags/TagsSection.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagsSection.tsx @@ -73,7 +73,9 @@ const TagsSection: FunctionComponent = () => { hasMigration={hasMigration} onClickMigration={runMigration} /> - + {!application.navigationController.isSearching && ( + + )}
diff --git a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts index 809f09b54d9..60951fbb66a 100644 --- a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts +++ b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts @@ -33,7 +33,7 @@ import { } from '@standardnotes/snjs' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { FeaturesController } from '../FeaturesController' -import { destroyAllObjectProperties } from '@/Utils' +import { debounce, destroyAllObjectProperties } from '@/Utils' import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils' import { AnyTag } from './AnyTagType' import { CrossControllerEvent } from '../CrossControllerEvent' @@ -65,6 +65,8 @@ export class NavigationController contextMenuTag: SNTag | undefined = undefined contextMenuTagSection: TagListSectionType | undefined = undefined + searchQuery = '' + private readonly tagsCountsState: TagsCountsState constructor( @@ -130,6 +132,9 @@ export class NavigationController isInFilesView: computed, hydrateFromPersistedValue: action, + + searchQuery: observable, + setSearchQuery: action, }) this.disposers.push( @@ -196,13 +201,20 @@ export class NavigationController }, }), ) + + this.setDisplayOptionsAndReloadTags = debounce(this.setDisplayOptionsAndReloadTags, 50) } private reloadTags(): void { runInAction(() => { this.tags = this.items.getDisplayableTags() this.starredTags = this.tags.filter((tag) => tag.starred) - this.smartViews = this.items.getSmartViews() + this.smartViews = this.items.getSmartViews().filter((view) => { + if (!this.isSearching) { + return true + } + return !isSystemView(view) + }) }) } @@ -377,7 +389,7 @@ export class NavigationController const children = this.items.getTagChildren(tag) const childrenUuids = children.map((childTag) => childTag.uuid) - const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid)) + const childrenTags = this.isSearching ? children : this.tags.filter((tag) => childrenUuids.includes(tag.uuid)) return childrenTags } @@ -656,4 +668,23 @@ export class NavigationController }) } } + + private setDisplayOptionsAndReloadTags = () => { + this.items.setTagsAndViewsDisplayOptions({ + searchQuery: { + query: this.searchQuery, + includeProtectedNoteText: false, + }, + }) + this.reloadTags() + } + + public setSearchQuery = (query: string) => { + this.searchQuery = query + this.setDisplayOptionsAndReloadTags() + } + + public get isSearching(): boolean { + return this.searchQuery.length > 0 + } } diff --git a/packages/web/src/javascripts/Hooks/useListKeyboardNavigation.ts b/packages/web/src/javascripts/Hooks/useListKeyboardNavigation.ts index a29965b84d3..2b65490c73b 100644 --- a/packages/web/src/javascripts/Hooks/useListKeyboardNavigation.ts +++ b/packages/web/src/javascripts/Hooks/useListKeyboardNavigation.ts @@ -1,58 +1,81 @@ import { KeyboardKey } from '@standardnotes/ui-services' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' -import { useCallback, useEffect, RefObject, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' + +type Options = { + initialFocus?: number + shouldAutoFocus?: boolean + shouldWrapAround?: boolean + resetLastFocusedOnBlur?: boolean +} + +export const useListKeyboardNavigation = (containerElement: HTMLElement | null, options?: Options) => { + const { + initialFocus = 0, + shouldAutoFocus = false, + shouldWrapAround = true, + resetLastFocusedOnBlur = false, + } = options || {} -export const useListKeyboardNavigation = ( - container: RefObject, - initialFocus = 0, - shouldAutoFocus = false, -) => { const listItems = useRef([]) + const setLatestListItems = useCallback(() => { + if (!containerElement) { + return + } + listItems.current = Array.from(containerElement.querySelectorAll('button, div[role="button"]')) + if (listItems.current.length > 0) { + listItems.current[0].tabIndex = 0 + } + }, [containerElement]) + const focusedItemIndex = useRef(initialFocus) - const focusItemWithIndex = useCallback((index: number, items?: HTMLButtonElement[]) => { + const focusItemWithIndex = useCallback((index: number) => { focusedItemIndex.current = index - if (items && items.length > 0) { - items[index]?.focus() - } else { - listItems.current[index]?.focus() - } + listItems.current[index]?.focus() }, []) - const getNextFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => { - let nextIndex = currentIndex + 1 - if (nextIndex > items.length - 1) { - nextIndex = 0 - } - while (items[nextIndex].disabled) { - nextIndex++ + const getNextFocusableIndex = useCallback( + (currentIndex: number, items: HTMLButtonElement[]) => { + let nextIndex = currentIndex + 1 if (nextIndex > items.length - 1) { - nextIndex = 0 + nextIndex = shouldWrapAround ? 0 : currentIndex } - } - return nextIndex - }, []) + while (items[nextIndex].disabled) { + nextIndex++ + if (nextIndex > items.length - 1) { + nextIndex = shouldWrapAround ? 0 : currentIndex + } + } + return nextIndex + }, + [shouldWrapAround], + ) - const getPreviousFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => { - let previousIndex = currentIndex - 1 - if (previousIndex < 0) { - previousIndex = items.length - 1 - } - while (items[previousIndex].disabled) { - previousIndex-- + const getPreviousFocusableIndex = useCallback( + (currentIndex: number, items: HTMLButtonElement[]) => { + let previousIndex = currentIndex - 1 if (previousIndex < 0) { - previousIndex = items.length - 1 + previousIndex = shouldWrapAround ? items.length - 1 : currentIndex } - } - return previousIndex - }, []) + while (items[previousIndex].disabled) { + previousIndex-- + if (previousIndex < 0) { + previousIndex = shouldWrapAround ? items.length - 1 : currentIndex + } + } + return previousIndex + }, + [shouldWrapAround], + ) useEffect(() => { - if (container.current) { - container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE - listItems.current = Array.from(container.current.querySelectorAll('button')) + if (containerElement) { + containerElement.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE + setLatestListItems() + listItems.current[0].tabIndex = 0 } - }, [container]) + }, [containerElement, setLatestListItems]) const keyDownHandler = useCallback( (e: KeyboardEvent) => { @@ -95,7 +118,7 @@ export const useListKeyboardNavigation = ( let indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus indexToFocus = getNextFocusableIndex(indexToFocus - 1, items) - focusItemWithIndex(indexToFocus, items) + focusItemWithIndex(indexToFocus) }, [focusItemWithIndex, getNextFocusableIndex, initialFocus]) useEffect(() => { @@ -106,23 +129,27 @@ export const useListKeyboardNavigation = ( } }, [setInitialFocus, shouldAutoFocus]) - useEffect(() => { - if (listItems.current.length > 0) { - listItems.current[0].tabIndex = 0 - } - }, []) + const focusOutHandler = useCallback( + (event: FocusEvent) => { + const isFocusInContainer = containerElement && containerElement.contains(event.relatedTarget as Node) + if (isFocusInContainer || !resetLastFocusedOnBlur) { + return + } + focusedItemIndex.current = initialFocus + }, + [containerElement, initialFocus, resetLastFocusedOnBlur], + ) useEffect(() => { - const containerElement = container.current - if (!containerElement) { return } containerElement.addEventListener('keydown', keyDownHandler) + containerElement.addEventListener('focusout', focusOutHandler) const containerMutationObserver = new MutationObserver(() => { - listItems.current = Array.from(containerElement.querySelectorAll('button')) + setLatestListItems() }) containerMutationObserver.observe(containerElement, { @@ -131,10 +158,11 @@ export const useListKeyboardNavigation = ( }) return () => { - containerElement?.removeEventListener('keydown', keyDownHandler) + containerElement.removeEventListener('keydown', keyDownHandler) + containerElement.removeEventListener('focusout', focusOutHandler) containerMutationObserver.disconnect() } - }, [container, setInitialFocus, keyDownHandler]) + }, [setInitialFocus, keyDownHandler, focusOutHandler, containerElement, setLatestListItems]) return { setInitialFocus, diff --git a/packages/web/src/stylesheets/_navigation.scss b/packages/web/src/stylesheets/_navigation.scss index af4dc6be2bb..4ddb5198dc1 100644 --- a/packages/web/src/stylesheets/_navigation.scss +++ b/packages/web/src/stylesheets/_navigation.scss @@ -26,10 +26,6 @@ $content-horizontal-padding: 16px; font-size: 12px; } - .no-tags-placeholder { - padding: 0px $content-horizontal-padding; - } - .root-drop { width: '100%'; padding: 12px; @@ -50,6 +46,10 @@ $content-horizontal-padding: 16px; .tag { border: 0; background-color: transparent; + + &:focus:not(.selected) { + background-color: var(--navigation-item-selected-background-color); + } } .tag,