feat: Added search bar to navigation panel for searching tags and smart views (#2815)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user