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 customFilter?: DisplayControllerCustomFilter
} }
export type TagsDisplayOptions = GenericDisplayOptions export interface TagsAndViewsDisplayOptions extends GenericDisplayOptions {
searchQuery?: SearchQuery
customFilter?: DisplayControllerCustomFilter
}
export interface DisplayControllerDisplayOptions extends GenericDisplayOptions { export interface DisplayControllerDisplayOptions extends GenericDisplayOptions {
sortBy: CollectionSortProperty sortBy: CollectionSortProperty
@@ -27,5 +30,5 @@ export interface DisplayControllerDisplayOptions extends GenericDisplayOptions {
} }
export type NotesAndFilesDisplayControllerOptions = NotesAndFilesDisplayOptions & DisplayControllerDisplayOptions export type NotesAndFilesDisplayControllerOptions = NotesAndFilesDisplayOptions & DisplayControllerDisplayOptions
export type TagsDisplayControllerOptions = TagsDisplayOptions & DisplayControllerDisplayOptions export type TagsDisplayControllerOptions = TagsAndViewsDisplayOptions & DisplayControllerDisplayOptions
export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsDisplayOptions | GenericDisplayOptions export type AnyDisplayOptions = NotesAndFilesDisplayOptions | TagsAndViewsDisplayOptions | GenericDisplayOptions

View File

@@ -21,6 +21,7 @@ import {
NotesAndFilesDisplayControllerOptions, NotesAndFilesDisplayControllerOptions,
ComponentInterface, ComponentInterface,
ItemStream, ItemStream,
TagsAndViewsDisplayOptions,
} from '@standardnotes/models' } from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService' import { AbstractService } from '../Service/AbstractService'
@@ -130,6 +131,7 @@ export interface ItemManagerInterface extends AbstractService {
getDisplayableNotes(): SNNote[] getDisplayableNotes(): SNNote[]
getDisplayableNotesAndFiles(): (SNNote | FileItem)[] getDisplayableNotesAndFiles(): (SNNote | FileItem)[]
setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void
setTagsAndViewsDisplayOptions(options: TagsAndViewsDisplayOptions): void
getTagPrefixTitle(tag: SNTag): string | undefined getTagPrefixTitle(tag: SNTag): string | undefined
getItemLinkedFiles(item: DecryptedItemInterface): FileItem[] getItemLinkedFiles(item: DecryptedItemInterface): FileItem[]
getItemLinkedNotes(item: DecryptedItemInterface): SNNote[] getItemLinkedNotes(item: DecryptedItemInterface): SNNote[]

View File

@@ -34,12 +34,12 @@ export class ItemManager extends Services.AbstractService implements Services.It
Models.SNNote | Models.FileItem, Models.SNNote | Models.FileItem,
Models.NotesAndFilesDisplayOptions Models.NotesAndFilesDisplayOptions
> >
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsDisplayOptions> private tagDisplayController!: Models.ItemDisplayController<Models.SNTag, Models.TagsAndViewsDisplayOptions>
private itemsKeyDisplayController!: Models.ItemDisplayController<SNItemsKey> private itemsKeyDisplayController!: Models.ItemDisplayController<SNItemsKey>
private componentDisplayController!: Models.ItemDisplayController<Models.ComponentInterface> private componentDisplayController!: Models.ItemDisplayController<Models.ComponentInterface>
private themeDisplayController!: Models.ItemDisplayController<Models.ComponentInterface> private themeDisplayController!: Models.ItemDisplayController<Models.ComponentInterface>
private fileDisplayController!: Models.ItemDisplayController<Models.FileItem> private fileDisplayController!: Models.ItemDisplayController<Models.FileItem>
private smartViewDisplayController!: Models.ItemDisplayController<Models.SmartView> private smartViewDisplayController!: Models.ItemDisplayController<Models.SmartView, Models.TagsAndViewsDisplayOptions>
constructor( constructor(
private payloadManager: PayloadManager, private payloadManager: PayloadManager,
@@ -73,10 +73,14 @@ export class ItemManager extends Services.AbstractService implements Services.It
hiddenContentTypes: [], hiddenContentTypes: [],
}, },
) )
this.tagDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.Tag], { this.tagDisplayController = new Models.ItemDisplayController<Models.SNTag, Models.TagsAndViewsDisplayOptions>(
sortBy: 'title', this.collection,
sortDirection: 'asc', [ContentType.TYPES.Tag],
}) {
sortBy: 'title',
sortDirection: 'asc',
},
)
this.itemsKeyDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.ItemsKey], { this.itemsKeyDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.TYPES.ItemsKey], {
sortBy: 'created_at', sortBy: 'created_at',
sortDirection: 'asc', sortDirection: 'asc',
@@ -89,7 +93,10 @@ export class ItemManager extends Services.AbstractService implements Services.It
sortBy: 'title', sortBy: 'title',
sortDirection: 'asc', 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', sortBy: 'title',
sortDirection: 'asc', sortDirection: 'asc',
}) })
@@ -194,6 +201,16 @@ export class ItemManager extends Services.AbstractService implements Services.It
this.itemCounter.setDisplayOptions(updatedOptions) 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 { public setVaultDisplayOptions(options: Models.VaultDisplayOptions): void {
this.navigationDisplayController.setVaultDisplayOptions(options) this.navigationDisplayController.setVaultDisplayOptions(options)
this.tagDisplayController.setVaultDisplayOptions(options) this.tagDisplayController.setVaultDisplayOptions(options)

View File

@@ -4,7 +4,7 @@ import {
KeyboardEventHandler, KeyboardEventHandler,
useCallback, useCallback,
useImperativeHandle, useImperativeHandle,
useRef, useState,
} from 'react' } from 'react'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
@@ -33,7 +33,7 @@ const Menu = forwardRef(
}: MenuProps, }: MenuProps,
forwardedRef, forwardedRef,
) => { ) => {
const menuElementRef = useRef<HTMLMenuElement>(null) const [menuElement, setMenuElement] = useState<HTMLMenuElement | null>(null)
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback( const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => { (event) => {
@@ -49,11 +49,10 @@ const Menu = forwardRef(
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const { setInitialFocus } = useListKeyboardNavigation( const { setInitialFocus } = useListKeyboardNavigation(menuElement, {
menuElementRef,
initialFocus, initialFocus,
isMobileScreen ? false : shouldAutoFocus, shouldAutoFocus: isMobileScreen ? false : shouldAutoFocus,
) })
useImperativeHandle(forwardedRef, () => ({ useImperativeHandle(forwardedRef, () => ({
focus: () => { focus: () => {
@@ -65,7 +64,7 @@ const Menu = forwardRef(
<menu <menu
className={`m-0 list-none px-4 focus:shadow-none md:px-0 ${className}`} className={`m-0 list-none px-4 focus:shadow-none md:px-0 ${className}`}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
ref={mergeRefs([menuElementRef, forwardedRef])} ref={mergeRefs([setMenuElement, forwardedRef])}
style={style} style={style}
aria-label={a11yLabel} aria-label={a11yLabel}
{...props} {...props}

View File

@@ -1,6 +1,6 @@
import { NoteType, SNNote, classNames } from '@standardnotes/snjs' import { NoteType, SNNote, classNames } from '@standardnotes/snjs'
import Modal, { ModalAction } from '../../Modal/Modal' 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 { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useApplication } from '../../ApplicationProvider' import { useApplication } from '../../ApplicationProvider'
import { confirmDialog } from '@standardnotes/ui-services' import { confirmDialog } from '@standardnotes/ui-services'
@@ -134,8 +134,8 @@ const NoteConflictResolutionModal = ({
[close], [close],
) )
const listRef = useRef<HTMLDivElement>(null) const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(listRef) useListKeyboardNavigation(listElement)
const [selectedMobileTab, setSelectedMobileTab] = useState<'list' | 'preview'>('list') 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', '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', selectedMobileTab !== 'list' && 'hidden md:flex',
)} )}
ref={listRef} ref={setListElement}
> >
{allVersions.map((note, index) => ( {allVersions.map((note, index) => (
<ConflictListItem <ConflictListItem

View File

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

View File

@@ -1,5 +1,5 @@
import { observer } from 'mobx-react-lite' 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 Icon from '@/Components/Icon/Icon'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
import HistoryListItem from './HistoryListItem' import HistoryListItem from './HistoryListItem'
@@ -22,9 +22,9 @@ const RemoteHistoryList: FunctionComponent<RemoteHistoryListProps> = ({
}) => { }) => {
const { remoteHistory, isFetchingRemoteHistory, selectRemoteRevision, selectedEntry } = noteHistoryController 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]) 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 ${ className={`flex h-full w-full flex-col focus:shadow-none ${
isFetchingRemoteHistory || !remoteHistoryLength ? 'items-center justify-center' : '' isFetchingRemoteHistory || !remoteHistoryLength ? 'items-center justify-center' : ''
}`} }`}
ref={remoteHistoryListRef} ref={setListElement}
> >
{isFetchingRemoteHistory && <Spinner className="h-5 w-5" />} {isFetchingRemoteHistory && <Spinner className="h-5 w-5" />}
{remoteHistory?.map((group) => { {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 { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
import HistoryListItem from './HistoryListItem' import HistoryListItem from './HistoryListItem'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
@@ -12,9 +12,9 @@ type Props = {
const SessionHistoryList: FunctionComponent<Props> = ({ noteHistoryController, onSelectRevision }) => { const SessionHistoryList: FunctionComponent<Props> = ({ noteHistoryController, onSelectRevision }) => {
const { sessionHistory, selectedRevision, selectSessionRevision } = noteHistoryController const { sessionHistory, selectedRevision, selectSessionRevision } = noteHistoryController
const sessionHistoryListRef = useRef<HTMLDivElement>(null) const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
useListKeyboardNavigation(sessionHistoryListRef) useListKeyboardNavigation(listElement)
const sessionHistoryLength = useMemo( const sessionHistoryLength = useMemo(
() => sessionHistory?.map((group) => group.entries).flat().length, () => 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 ${ className={`flex h-full w-full flex-col focus:shadow-none ${
!sessionHistoryLength ? 'items-center justify-center' : '' !sessionHistoryLength ? 'items-center justify-center' : ''
}`} }`}
ref={sessionHistoryListRef} ref={setListElement}
> >
{sessionHistory?.map((group) => { {sessionHistory?.map((group) => {
if (group.entries && group.entries.length) { if (group.entries && group.entries.length) {

View File

@@ -17,6 +17,7 @@ import { useAvailableSafeAreaPadding } from '@/Hooks/useSafeAreaPadding'
import QuickSettingsButton from '../Footer/QuickSettingsButton' import QuickSettingsButton from '../Footer/QuickSettingsButton'
import VaultSelectionButton from '../Footer/VaultSelectionButton' import VaultSelectionButton from '../Footer/VaultSelectionButton'
import PreferencesButton from '../Footer/PreferencesButton' import PreferencesButton from '../Footer/PreferencesButton'
import TagSearchBar from './TagSearchBar'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -78,6 +79,7 @@ const Navigation = forwardRef<HTMLDivElement, Props>(({ application, className,
'md:hover:[overflow-y:_overlay] pointer-coarse:md:overflow-y-auto', 'md:hover:[overflow-y:_overlay] pointer-coarse:md:overflow-y-auto',
)} )}
> >
<TagSearchBar navigationController={application.navigationController} />
<SmartViewsSection <SmartViewsSection
application={application} application={application}
featuresController={application.featuresController} featuresController={application.featuresController}

View File

@@ -2,8 +2,9 @@ import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { SmartView } from '@standardnotes/snjs' import { SmartView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react' import { FunctionComponent, useState } from 'react'
import SmartViewsListItem from './SmartViewsListItem' import SmartViewsListItem from './SmartViewsListItem'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type Props = { type Props = {
navigationController: NavigationController navigationController: NavigationController
@@ -18,8 +19,23 @@ const SmartViewsList: FunctionComponent<Props> = ({
}: Props) => { }: Props) => {
const allViews = navigationController.smartViews 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 ( return (
<> <div ref={setContainer}>
{allViews.map((view) => { {allViews.map((view) => {
return ( return (
<SmartViewsListItem <SmartViewsListItem
@@ -31,7 +47,7 @@ const SmartViewsList: FunctionComponent<Props> = ({
/> />
) )
})} })}
</> </div>
) )
} }

View File

@@ -111,74 +111,73 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEdit
} }
return ( return (
<> <button
<div tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
role="button" className={classNames(
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} 'tag group px-3.5 py-0.5 focus-visible:!shadow-inner md:py-0',
className={classNames('tag group px-3.5 py-1 md:py-0', isSelected && 'selected', isFaded && 'opacity-50')} isSelected && 'selected',
onClick={selectCurrentTag} isFaded && 'opacity-50',
onContextMenu={(event) => { )}
event.preventDefault() onClick={selectCurrentTag}
event.stopPropagation() onContextMenu={(event) => {
if (isSystemView(view)) { event.preventDefault()
return event.stopPropagation()
} if (isSystemView(view)) {
onClickEdit() return
}} }
style={{ onClickEdit()
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, }}
}} 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 className="tag-info">
</div> <div className={'tag-icon mr-2'}>
{isEditing ? ( <Icon type={view.iconString} className={classNames(iconClass, 'group-hover:text-text')} />
<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> </div>
{isEditing ? (
{!isSystemView(view) && ( <input
<div className="meta"> className={'title editing text-mobile-navigation-list-item lg:text-navigation-list-item'}
{view.conflictOf && <div className="-mt-1 text-[0.625rem] font-bold text-danger">Conflicted Copy</div>} id={`react-tag-${view.uuid}`}
onBlur={onBlur}
{isSelected && ( onInput={onInput}
<div className="menu"> value={title}
<a className="item" onClick={onClickEdit}> onKeyUp={onKeyUp}
Edit spellCheck={false}
</a> ref={inputRef}
<a className="item" onClick={onClickDelete}> />
Delete ) : (
</a> <div
</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>
)} )}
<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> </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"> <div className="title text-base md:text-sm">
<span className="font-bold">Views</span> <span className="font-bold">Views</span>
</div> </div>
<IconButton {!navigationController.isSearching && (
focusable={true} <IconButton
icon="add" focusable={true}
title="Create a new smart view" icon="add"
className="p-0 text-neutral" title="Create a new smart view"
onClick={createNewSmartView} className="p-0 text-neutral"
/> onClick={createNewSmartView}
/>
)}
</div> </div>
</div> </div>
<SmartViewsList <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 { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback } from 'react' import { FunctionComponent, useCallback, useState } from 'react'
import RootTagDropZone from './RootTagDropZone' import RootTagDropZone from './RootTagDropZone'
import { TagListSectionType } from './TagListSection' import { TagListSectionType } from './TagListSection'
import { TagsListItem } from './TagsListItem' import { TagsListItem } from './TagsListItem'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type Props = { type Props = {
type: TagListSectionType type: TagListSectionType
@@ -32,31 +33,44 @@ const TagsList: FunctionComponent<Props> = ({ type }: Props) => {
[application, openTagContextMenu], [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 ( return (
<> <>
{allTags.length === 0 ? ( <div ref={setContainer}>
<div className="no-tags-placeholder text-base opacity-[0.4] lg:text-sm"> {allTags.map((tag) => {
No tags or folders. Create one using the add button above. return (
</div> <TagsListItem
) : ( level={0}
<> key={tag.uuid}
{allTags.map((tag) => { tag={tag}
return ( type={type}
<TagsListItem navigationController={application.navigationController}
level={0} features={application.featuresController}
key={tag.uuid} linkingController={application.linkingController}
tag={tag} onContextMenu={onContextMenu}
type={type} />
navigationController={application.navigationController} )
features={application.featuresController} })}
linkingController={application.linkingController} </div>
onContextMenu={onContextMenu} {type === 'all' && <RootTagDropZone tagsState={application.navigationController} />}
/>
)
})}
{type === 'all' && <RootTagDropZone tagsState={application.navigationController} />}
</>
)}
</> </>
) )
} }

View File

@@ -91,11 +91,19 @@ export const TagsListItem: FunctionComponent<Props> = observer(
e?.stopPropagation() e?.stopPropagation()
const shouldShowChildren = !showChildren const shouldShowChildren = !showChildren
setShowChildren(shouldShowChildren) setShowChildren(shouldShowChildren)
navigationController.setExpanded(tag, shouldShowChildren) if (!navigationController.isSearching) {
navigationController.setExpanded(tag, shouldShowChildren)
}
}, },
[showChildren, tag, navigationController], [showChildren, tag, navigationController],
) )
useEffect(() => {
if (!navigationController.isSearching) {
setShowChildren(tag.expanded)
}
}, [navigationController.isSearching, tag])
const selectCurrentTag = useCallback(async () => { const selectCurrentTag = useCallback(async () => {
await navigationController.setSelectedTag(tag, type, { await navigationController.setSelectedTag(tag, type, {
userTriggered: true, userTriggered: true,
@@ -269,11 +277,16 @@ export const TagsListItem: FunctionComponent<Props> = observer(
role="button" role="button"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames( 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', (isSelected || isContextMenuOpenForTag) && 'selected',
isBeingDraggedOver && 'is-drag-over', isBeingDraggedOver && 'is-drag-over',
)} )}
onClick={selectCurrentTag} onClick={selectCurrentTag}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter || event.key === KeyboardKey.Space) {
selectCurrentTag().catch(console.error)
}
}}
ref={tagRef} ref={tagRef}
style={{ style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
@@ -282,7 +295,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
e.preventDefault() e.preventDefault()
onContextMenu(tag, type, e.clientX, e.clientY) onContextMenu(tag, type, e.clientX, e.clientY)
}} }}
draggable={true} draggable={!navigationController.isSearching}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragExit={removeDragIndicator} onDragExit={removeDragIndicator}

View File

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

View File

@@ -33,7 +33,7 @@ import {
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { FeaturesController } from '../FeaturesController' import { FeaturesController } from '../FeaturesController'
import { destroyAllObjectProperties } from '@/Utils' import { debounce, destroyAllObjectProperties } from '@/Utils'
import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils' import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils'
import { AnyTag } from './AnyTagType' import { AnyTag } from './AnyTagType'
import { CrossControllerEvent } from '../CrossControllerEvent' import { CrossControllerEvent } from '../CrossControllerEvent'
@@ -65,6 +65,8 @@ export class NavigationController
contextMenuTag: SNTag | undefined = undefined contextMenuTag: SNTag | undefined = undefined
contextMenuTagSection: TagListSectionType | undefined = undefined contextMenuTagSection: TagListSectionType | undefined = undefined
searchQuery = ''
private readonly tagsCountsState: TagsCountsState private readonly tagsCountsState: TagsCountsState
constructor( constructor(
@@ -130,6 +132,9 @@ export class NavigationController
isInFilesView: computed, isInFilesView: computed,
hydrateFromPersistedValue: action, hydrateFromPersistedValue: action,
searchQuery: observable,
setSearchQuery: action,
}) })
this.disposers.push( this.disposers.push(
@@ -196,13 +201,20 @@ export class NavigationController
}, },
}), }),
) )
this.setDisplayOptionsAndReloadTags = debounce(this.setDisplayOptionsAndReloadTags, 50)
} }
private reloadTags(): void { private reloadTags(): void {
runInAction(() => { runInAction(() => {
this.tags = this.items.getDisplayableTags() this.tags = this.items.getDisplayableTags()
this.starredTags = this.tags.filter((tag) => tag.starred) 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 children = this.items.getTagChildren(tag)
const childrenUuids = children.map((childTag) => childTag.uuid) 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 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 { KeyboardKey } from '@standardnotes/ui-services'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' 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 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 focusedItemIndex = useRef<number>(initialFocus)
const focusItemWithIndex = useCallback((index: number, items?: HTMLButtonElement[]) => { const focusItemWithIndex = useCallback((index: number) => {
focusedItemIndex.current = index focusedItemIndex.current = index
if (items && items.length > 0) { listItems.current[index]?.focus()
items[index]?.focus()
} else {
listItems.current[index]?.focus()
}
}, []) }, [])
const getNextFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => { const getNextFocusableIndex = useCallback(
let nextIndex = currentIndex + 1 (currentIndex: number, items: HTMLButtonElement[]) => {
if (nextIndex > items.length - 1) { let nextIndex = currentIndex + 1
nextIndex = 0
}
while (items[nextIndex].disabled) {
nextIndex++
if (nextIndex > items.length - 1) { if (nextIndex > items.length - 1) {
nextIndex = 0 nextIndex = shouldWrapAround ? 0 : currentIndex
} }
} while (items[nextIndex].disabled) {
return nextIndex nextIndex++
}, []) if (nextIndex > items.length - 1) {
nextIndex = shouldWrapAround ? 0 : currentIndex
}
}
return nextIndex
},
[shouldWrapAround],
)
const getPreviousFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => { const getPreviousFocusableIndex = useCallback(
let previousIndex = currentIndex - 1 (currentIndex: number, items: HTMLButtonElement[]) => {
if (previousIndex < 0) { let previousIndex = currentIndex - 1
previousIndex = items.length - 1
}
while (items[previousIndex].disabled) {
previousIndex--
if (previousIndex < 0) { if (previousIndex < 0) {
previousIndex = items.length - 1 previousIndex = shouldWrapAround ? items.length - 1 : currentIndex
} }
} while (items[previousIndex].disabled) {
return previousIndex previousIndex--
}, []) if (previousIndex < 0) {
previousIndex = shouldWrapAround ? items.length - 1 : currentIndex
}
}
return previousIndex
},
[shouldWrapAround],
)
useEffect(() => { useEffect(() => {
if (container.current) { if (containerElement) {
container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE containerElement.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE
listItems.current = Array.from(container.current.querySelectorAll('button')) setLatestListItems()
listItems.current[0].tabIndex = 0
} }
}, [container]) }, [containerElement, setLatestListItems])
const keyDownHandler = useCallback( const keyDownHandler = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -95,7 +118,7 @@ export const useListKeyboardNavigation = (
let indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus let indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus
indexToFocus = getNextFocusableIndex(indexToFocus - 1, items) indexToFocus = getNextFocusableIndex(indexToFocus - 1, items)
focusItemWithIndex(indexToFocus, items) focusItemWithIndex(indexToFocus)
}, [focusItemWithIndex, getNextFocusableIndex, initialFocus]) }, [focusItemWithIndex, getNextFocusableIndex, initialFocus])
useEffect(() => { useEffect(() => {
@@ -106,23 +129,27 @@ export const useListKeyboardNavigation = (
} }
}, [setInitialFocus, shouldAutoFocus]) }, [setInitialFocus, shouldAutoFocus])
useEffect(() => { const focusOutHandler = useCallback(
if (listItems.current.length > 0) { (event: FocusEvent) => {
listItems.current[0].tabIndex = 0 const isFocusInContainer = containerElement && containerElement.contains(event.relatedTarget as Node)
} if (isFocusInContainer || !resetLastFocusedOnBlur) {
}, []) return
}
focusedItemIndex.current = initialFocus
},
[containerElement, initialFocus, resetLastFocusedOnBlur],
)
useEffect(() => { useEffect(() => {
const containerElement = container.current
if (!containerElement) { if (!containerElement) {
return return
} }
containerElement.addEventListener('keydown', keyDownHandler) containerElement.addEventListener('keydown', keyDownHandler)
containerElement.addEventListener('focusout', focusOutHandler)
const containerMutationObserver = new MutationObserver(() => { const containerMutationObserver = new MutationObserver(() => {
listItems.current = Array.from(containerElement.querySelectorAll('button')) setLatestListItems()
}) })
containerMutationObserver.observe(containerElement, { containerMutationObserver.observe(containerElement, {
@@ -131,10 +158,11 @@ export const useListKeyboardNavigation = (
}) })
return () => { return () => {
containerElement?.removeEventListener('keydown', keyDownHandler) containerElement.removeEventListener('keydown', keyDownHandler)
containerElement.removeEventListener('focusout', focusOutHandler)
containerMutationObserver.disconnect() containerMutationObserver.disconnect()
} }
}, [container, setInitialFocus, keyDownHandler]) }, [setInitialFocus, keyDownHandler, focusOutHandler, containerElement, setLatestListItems])
return { return {
setInitialFocus, setInitialFocus,

View File

@@ -26,10 +26,6 @@ $content-horizontal-padding: 16px;
font-size: 12px; font-size: 12px;
} }
.no-tags-placeholder {
padding: 0px $content-horizontal-padding;
}
.root-drop { .root-drop {
width: '100%'; width: '100%';
padding: 12px; padding: 12px;
@@ -50,6 +46,10 @@ $content-horizontal-padding: 16px;
.tag { .tag {
border: 0; border: 0;
background-color: transparent; background-color: transparent;
&:focus:not(.selected) {
background-color: var(--navigation-item-selected-background-color);
}
} }
.tag, .tag,