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

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