feat: Added search bar to navigation panel for searching tags and smart views (#2815)

This commit is contained in:
Aman Harwara
2024-02-02 16:07:55 +05:30
committed by GitHub
parent 50c1977410
commit b07abaa5df
19 changed files with 396 additions and 191 deletions

View File

@@ -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

View File

@@ -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[]

View File

@@ -34,12 +34,12 @@ export class ItemManager extends Services.AbstractService implements Services.It
Models.SNNote | Models.FileItem,
Models.NotesAndFilesDisplayOptions
>
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsDisplayOptions>
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsAndViewsDisplayOptions>
private itemsKeyDisplayController!: Models.ItemDisplayController<SNItemsKey>
private componentDisplayController!: Models.ItemDisplayController<Models.ComponentInterface>
private themeDisplayController!: Models.ItemDisplayController<Models.ComponentInterface>
private fileDisplayController!: Models.ItemDisplayController<Models.FileItem>
private smartViewDisplayController!: Models.ItemDisplayController<Models.SmartView>
private smartViewDisplayController!: Models.ItemDisplayController<Models.SmartView, Models.TagsAndViewsDisplayOptions>
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<Models.SNTag, Models.TagsAndViewsDisplayOptions>(
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)

View File

@@ -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<HTMLMenuElement>(null)
const [menuElement, setMenuElement] = useState<HTMLMenuElement | null>(null)
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = 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(
<menu
className={`m-0 list-none px-4 focus:shadow-none md:px-0 ${className}`}
onKeyDown={handleKeyDown}
ref={mergeRefs([menuElementRef, forwardedRef])}
ref={mergeRefs([setMenuElement, forwardedRef])}
style={style}
aria-label={a11yLabel}
{...props}

View File

@@ -1,6 +1,6 @@
import { NoteType, SNNote, classNames } from '@standardnotes/snjs'
import Modal, { ModalAction } from '../../Modal/Modal'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useApplication } from '../../ApplicationProvider'
import { confirmDialog } from '@standardnotes/ui-services'
@@ -134,8 +134,8 @@ const NoteConflictResolutionModal = ({
[close],
)
const listRef = useRef<HTMLDivElement>(null)
useListKeyboardNavigation(listRef)
const [listElement, setListElement] = useState<HTMLDivElement | null>(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) => (
<ConflictListItem

View File

@@ -1,5 +1,5 @@
import { Action } from '@standardnotes/snjs'
import { FunctionComponent, useRef } from 'react'
import { FunctionComponent, useState } from 'react'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
import HistoryListItem from './HistoryListItem'
import { NoteHistoryController } from '@/Controllers/NoteHistory/NoteHistoryController'
@@ -13,16 +13,16 @@ type Props = {
const LegacyHistoryList: FunctionComponent<Props> = ({ legacyHistory, noteHistoryController, onSelectRevision }) => {
const { selectLegacyRevision, selectedEntry } = noteHistoryController
const legacyHistoryListRef = useRef<HTMLDivElement>(null)
const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(legacyHistoryListRef)
useListKeyboardNavigation(listElement)
return (
<div
className={`flex h-full w-full flex-col focus:shadow-none ${
!legacyHistory?.length ? 'items-center justify-center' : ''
}`}
ref={legacyHistoryListRef}
ref={setListElement}
>
{legacyHistory?.map((entry) => {
const selectedEntryUrl = (selectedEntry as Action)?.subactions?.[0].url

View File

@@ -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<RemoteHistoryListProps> = ({
}) => {
const { remoteHistory, isFetchingRemoteHistory, selectRemoteRevision, selectedEntry } = noteHistoryController
const remoteHistoryListRef = useRef<HTMLDivElement>(null)
const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(remoteHistoryListRef)
useListKeyboardNavigation(listElement)
const remoteHistoryLength = useMemo(() => remoteHistory?.map((group) => group.entries).flat().length, [remoteHistory])
@@ -33,7 +33,7 @@ const RemoteHistoryList: FunctionComponent<RemoteHistoryListProps> = ({
className={`flex h-full w-full flex-col focus:shadow-none ${
isFetchingRemoteHistory || !remoteHistoryLength ? 'items-center justify-center' : ''
}`}
ref={remoteHistoryListRef}
ref={setListElement}
>
{isFetchingRemoteHistory && <Spinner className="h-5 w-5" />}
{remoteHistory?.map((group) => {

View File

@@ -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<Props> = ({ noteHistoryController, onSelectRevision }) => {
const { sessionHistory, selectedRevision, selectSessionRevision } = noteHistoryController
const sessionHistoryListRef = useRef<HTMLDivElement>(null)
const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(sessionHistoryListRef)
useListKeyboardNavigation(listElement)
const sessionHistoryLength = useMemo(
() => sessionHistory?.map((group) => group.entries).flat().length,
@@ -26,7 +26,7 @@ const SessionHistoryList: FunctionComponent<Props> = ({ 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) {

View File

@@ -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<HTMLDivElement, Props>(({ application, className,
'md:hover:[overflow-y:_overlay] pointer-coarse:md:overflow-y-auto',
)}
>
<TagSearchBar navigationController={application.navigationController} />
<SmartViewsSection
application={application}
featuresController={application.featuresController}

View File

@@ -2,8 +2,9 @@ import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { SmartView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { FunctionComponent, useState } from 'react'
import SmartViewsListItem from './SmartViewsListItem'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type Props = {
navigationController: NavigationController
@@ -18,8 +19,23 @@ const SmartViewsList: FunctionComponent<Props> = ({
}: Props) => {
const allViews = navigationController.smartViews
const [container, setContainer] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(container, {
initialFocus: 0,
shouldAutoFocus: false,
shouldWrapAround: false,
resetLastFocusedOnBlur: true,
})
if (allViews.length === 0 && navigationController.isSearching) {
return (
<div className="px-4 py-1 text-base opacity-60 lg:text-sm">No smart views found. Try a different search.</div>
)
}
return (
<>
<div ref={setContainer}>
{allViews.map((view) => {
return (
<SmartViewsListItem
@@ -31,7 +47,7 @@ const SmartViewsList: FunctionComponent<Props> = ({
/>
)
})}
</>
</div>
)
}

View File

@@ -111,74 +111,73 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEdit
}
return (
<>
<div
role="button"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames('tag group px-3.5 py-1 md:py-0', isSelected && 'selected', isFaded && 'opacity-50')}
onClick={selectCurrentTag}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
if (isSystemView(view)) {
return
}
onClickEdit()
}}
style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
>
<div className="tag-info">
<div className={'tag-icon mr-2'}>
<Icon type={view.iconString} className={classNames(iconClass, 'group-hover:text-text')} />
</div>
{isEditing ? (
<input
className={'title editing text-mobile-navigation-list-item lg:text-navigation-list-item'}
id={`react-tag-${view.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
) : (
<div
className={
'title overflow-hidden text-left text-mobile-navigation-list-item lg:text-navigation-list-item'
}
id={`react-tag-${view.uuid}`}
>
{title}
</div>
)}
<div className={'count text-base lg:text-sm'}>
{view.uuid === SystemViewId.AllNotes && tagsState.allNotesCount}
{view.uuid === SystemViewId.Files && tagsState.allFilesCount}
{view.uuid === SystemViewId.Conflicts && conflictsCount}
</div>
<button
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames(
'tag group px-3.5 py-0.5 focus-visible:!shadow-inner md:py-0',
isSelected && 'selected',
isFaded && 'opacity-50',
)}
onClick={selectCurrentTag}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
if (isSystemView(view)) {
return
}
onClickEdit()
}}
style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
>
<div className="tag-info">
<div className={'tag-icon mr-2'}>
<Icon type={view.iconString} className={classNames(iconClass, 'group-hover:text-text')} />
</div>
{!isSystemView(view) && (
<div className="meta">
{view.conflictOf && <div className="-mt-1 text-[0.625rem] font-bold text-danger">Conflicted Copy</div>}
{isSelected && (
<div className="menu">
<a className="item" onClick={onClickEdit}>
Edit
</a>
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
)}
{isEditing ? (
<input
className={'title editing text-mobile-navigation-list-item lg:text-navigation-list-item'}
id={`react-tag-${view.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
) : (
<div
className={'title overflow-hidden text-left text-mobile-navigation-list-item lg:text-navigation-list-item'}
id={`react-tag-${view.uuid}`}
>
{title}
</div>
)}
<div className={'count text-base lg:text-sm'}>
{view.uuid === SystemViewId.AllNotes && tagsState.allNotesCount}
{view.uuid === SystemViewId.Files && tagsState.allFilesCount}
{view.uuid === SystemViewId.Conflicts && conflictsCount}
</div>
</div>
</>
{!isSystemView(view) && (
<div className="meta">
{view.conflictOf && <div className="-mt-1 text-[0.625rem] font-bold text-danger">Conflicted Copy</div>}
{isSelected && (
<div className="menu">
<a className="item" onClick={onClickEdit}>
Edit
</a>
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
)}
</div>
)}
</button>
)
}

View File

@@ -40,13 +40,15 @@ const SmartViewsSection: FunctionComponent<Props> = ({ application, navigationCo
<div className="title text-base md:text-sm">
<span className="font-bold">Views</span>
</div>
<IconButton
focusable={true}
icon="add"
title="Create a new smart view"
className="p-0 text-neutral"
onClick={createNewSmartView}
/>
{!navigationController.isSearching && (
<IconButton
focusable={true}
icon="add"
title="Create a new smart view"
className="p-0 text-neutral"
onClick={createNewSmartView}
/>
)}
</div>
</div>
<SmartViewsList

View File

@@ -0,0 +1,77 @@
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import { observer } from 'mobx-react-lite'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
import { useCallback, useEffect, useRef, useState } from 'react'
import { classNames } from '@standardnotes/snjs'
type Props = {
navigationController: NavigationController
}
const TagSearchBar = ({ navigationController }: Props) => {
const { searchQuery, setSearchQuery } = navigationController
const inputRef = useRef<HTMLInputElement>(null)
const onClearSearch = useCallback(() => {
setSearchQuery('')
inputRef.current?.focus()
}, [setSearchQuery])
const [isParentScrolling, setIsParentScrolling] = useState(false)
const searchBarRef = useRef<HTMLDivElement>(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 (
<div
className={classNames(
'sticky top-0 bg-[inherit] px-4 pt-4',
isParentScrolling &&
'after:absolute after:left-0 after:top-full after:-z-[1] after:block after:h-4 after:w-full after:border-b after:border-border after:bg-[inherit]',
)}
role="search"
ref={searchBarRef}
>
<DecoratedInput
ref={inputRef}
autocomplete={false}
className={{
container: '!bg-default px-1',
input: 'text-base placeholder:text-passive-0 lg:text-sm',
}}
placeholder={'Search tags...'}
value={searchQuery}
onChange={setSearchQuery}
left={[<Icon type="search" className="mr-1 h-4.5 w-4.5 flex-shrink-0 text-passive-1" />]}
right={[searchQuery && <ClearInputButton onClick={onClearSearch} />]}
roundedFull
/>
</div>
)
}
export default observer(TagSearchBar)

View File

@@ -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<Props> = ({ type }: Props) => {
[application, openTagContextMenu],
)
const [container, setContainer] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(container, {
initialFocus: 0,
shouldAutoFocus: false,
shouldWrapAround: false,
resetLastFocusedOnBlur: true,
})
if (allTags.length === 0) {
return (
<div className="px-4 text-base opacity-50 lg:text-sm">
{application.navigationController.isSearching
? 'No tags found. Try a different search.'
: 'No tags or folders. Create one using the add button above.'}
</div>
)
}
return (
<>
{allTags.length === 0 ? (
<div className="no-tags-placeholder text-base opacity-[0.4] lg:text-sm">
No tags or folders. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
type={type}
navigationController={application.navigationController}
features={application.featuresController}
linkingController={application.linkingController}
onContextMenu={onContextMenu}
/>
)
})}
{type === 'all' && <RootTagDropZone tagsState={application.navigationController} />}
</>
)}
<div ref={setContainer}>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
type={type}
navigationController={application.navigationController}
features={application.featuresController}
linkingController={application.linkingController}
onContextMenu={onContextMenu}
/>
)
})}
</div>
{type === 'all' && <RootTagDropZone tagsState={application.navigationController} />}
</>
)
}

View File

@@ -91,11 +91,19 @@ export const TagsListItem: FunctionComponent<Props> = 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<Props> = 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<Props> = observer(
e.preventDefault()
onContextMenu(tag, type, e.clientX, e.clientY)
}}
draggable={true}
draggable={!navigationController.isSearching}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragExit={removeDragIndicator}

View File

@@ -73,7 +73,9 @@ const TagsSection: FunctionComponent = () => {
hasMigration={hasMigration}
onClickMigration={runMigration}
/>
<TagsSectionAddButton tags={application.navigationController} features={application.featuresController} />
{!application.navigationController.isSearching && (
<TagsSectionAddButton tags={application.navigationController} features={application.featuresController} />
)}
</div>
</div>
<TagsList type="all" />

View File

@@ -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
}
}

View File

@@ -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<HTMLElement | null>,
initialFocus = 0,
shouldAutoFocus = false,
) => {
const listItems = useRef<HTMLButtonElement[]>([])
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<number>(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,

View File

@@ -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,