-
-
Notifications
You must be signed in to change notification settings - Fork 440
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #567 from standardnotes/feature/autocomplete-tags
feat: autocomplete tags
- Loading branch information
Showing
24 changed files
with
1,024 additions
and
369 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { AppState } from '@/ui_models/app_state'; | ||
import { observer } from 'mobx-react-lite'; | ||
import { useRef, useEffect } from 'preact/hooks'; | ||
import { Icon } from './Icon'; | ||
|
||
type Props = { | ||
appState: AppState; | ||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; | ||
}; | ||
|
||
export const AutocompleteTagHint = observer( | ||
({ appState, closeOnBlur }: Props) => { | ||
const { autocompleteTagHintFocused } = appState.noteTags; | ||
|
||
const hintRef = useRef<HTMLButtonElement>(); | ||
|
||
const { autocompleteSearchQuery, autocompleteTagResults } = | ||
appState.noteTags; | ||
|
||
const onTagHintClick = async () => { | ||
await appState.noteTags.createAndAddNewTag(); | ||
}; | ||
|
||
const onFocus = () => { | ||
appState.noteTags.setAutocompleteTagHintFocused(true); | ||
}; | ||
|
||
const onBlur = (event: FocusEvent) => { | ||
closeOnBlur(event); | ||
appState.noteTags.setAutocompleteTagHintFocused(false); | ||
}; | ||
|
||
const onKeyDown = (event: KeyboardEvent) => { | ||
if (event.key === 'ArrowUp') { | ||
if (autocompleteTagResults.length > 0) { | ||
const lastTagResult = | ||
autocompleteTagResults[autocompleteTagResults.length - 1]; | ||
appState.noteTags.setFocusedTagResultUuid(lastTagResult.uuid); | ||
} else { | ||
appState.noteTags.setAutocompleteInputFocused(true); | ||
} | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
if (autocompleteTagHintFocused) { | ||
hintRef.current.focus(); | ||
} | ||
}, [appState.noteTags, autocompleteTagHintFocused]); | ||
|
||
return ( | ||
<> | ||
{autocompleteTagResults.length > 0 && ( | ||
<div className="h-1px my-2 bg-border"></div> | ||
)} | ||
<button | ||
ref={hintRef} | ||
type="button" | ||
className="sn-dropdown-item" | ||
onClick={onTagHintClick} | ||
onFocus={onFocus} | ||
onBlur={onBlur} | ||
onKeyDown={onKeyDown} | ||
> | ||
<span>Create new tag:</span> | ||
<span className="bg-contrast rounded text-xs color-text py-1 pl-1 pr-2 flex items-center ml-2"> | ||
<Icon | ||
type="hashtag" | ||
className="sn-icon--small color-neutral mr-1" | ||
/> | ||
<span className="max-w-40 whitespace-nowrap overflow-hidden overflow-ellipsis"> | ||
{autocompleteSearchQuery} | ||
</span> | ||
</span> | ||
</button> | ||
</> | ||
); | ||
} | ||
); |
140 changes: 140 additions & 0 deletions
140
app/assets/javascripts/components/AutocompleteTagInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
import { useEffect, useRef, useState } from 'preact/hooks'; | ||
import { Disclosure, DisclosurePanel } from '@reach/disclosure'; | ||
import { useCloseOnBlur } from './utils'; | ||
import { AppState } from '@/ui_models/app_state'; | ||
import { AutocompleteTagResult } from './AutocompleteTagResult'; | ||
import { AutocompleteTagHint } from './AutocompleteTagHint'; | ||
import { observer } from 'mobx-react-lite'; | ||
|
||
type Props = { | ||
appState: AppState; | ||
}; | ||
|
||
export const AutocompleteTagInput = observer(({ appState }: Props) => { | ||
const { | ||
autocompleteInputFocused, | ||
autocompleteSearchQuery, | ||
autocompleteTagHintVisible, | ||
autocompleteTagResults, | ||
tags, | ||
tagsContainerMaxWidth, | ||
} = appState.noteTags; | ||
|
||
const [dropdownVisible, setDropdownVisible] = useState(false); | ||
const [dropdownMaxHeight, setDropdownMaxHeight] = | ||
useState<number | 'auto'>('auto'); | ||
|
||
const dropdownRef = useRef<HTMLDivElement>(); | ||
const inputRef = useRef<HTMLInputElement>(); | ||
|
||
const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => { | ||
setDropdownVisible(visible); | ||
appState.noteTags.clearAutocompleteSearch(); | ||
}); | ||
|
||
const showDropdown = () => { | ||
const { clientHeight } = document.documentElement; | ||
const inputRect = inputRef.current.getBoundingClientRect(); | ||
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2); | ||
setDropdownVisible(true); | ||
}; | ||
|
||
const onSearchQueryChange = (event: Event) => { | ||
const query = (event.target as HTMLInputElement).value; | ||
appState.noteTags.setAutocompleteSearchQuery(query); | ||
appState.noteTags.searchActiveNoteAutocompleteTags(); | ||
}; | ||
|
||
const onFormSubmit = async (event: Event) => { | ||
event.preventDefault(); | ||
await appState.noteTags.createAndAddNewTag(); | ||
}; | ||
|
||
const onKeyDown = (event: KeyboardEvent) => { | ||
switch (event.key) { | ||
case 'Backspace': | ||
case 'ArrowLeft': | ||
if (autocompleteSearchQuery === '' && tags.length > 0) { | ||
appState.noteTags.setFocusedTagUuid(tags[tags.length - 1].uuid); | ||
} | ||
break; | ||
case 'ArrowDown': | ||
event.preventDefault(); | ||
if (autocompleteTagResults.length > 0) { | ||
appState.noteTags.setFocusedTagResultUuid(autocompleteTagResults[0].uuid); | ||
} else if (autocompleteTagHintVisible) { | ||
appState.noteTags.setAutocompleteTagHintFocused(true); | ||
} | ||
break; | ||
default: | ||
return; | ||
} | ||
}; | ||
|
||
const onFocus = () => { | ||
showDropdown(); | ||
appState.noteTags.setAutocompleteInputFocused(true); | ||
}; | ||
|
||
const onBlur = (event: FocusEvent) => { | ||
closeOnBlur(event); | ||
appState.noteTags.setAutocompleteInputFocused(false); | ||
}; | ||
|
||
useEffect(() => { | ||
appState.noteTags.searchActiveNoteAutocompleteTags(); | ||
}, [appState.noteTags]); | ||
|
||
useEffect(() => { | ||
if (autocompleteInputFocused) { | ||
inputRef.current.focus(); | ||
appState.noteTags.setAutocompleteInputFocused(false); | ||
} | ||
}, [appState.noteTags, autocompleteInputFocused]); | ||
|
||
return ( | ||
<form | ||
onSubmit={onFormSubmit} | ||
className={`${tags.length > 0 ? 'mt-2' : ''}`} | ||
> | ||
<Disclosure open={dropdownVisible} onChange={showDropdown}> | ||
<input | ||
ref={inputRef} | ||
className={`${tags.length > 0 ? 'w-80' : 'w-70 mr-10'} bg-default text-xs | ||
color-text no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom`} | ||
value={autocompleteSearchQuery} | ||
onChange={onSearchQueryChange} | ||
type="text" | ||
placeholder="Add tag" | ||
onBlur={onBlur} | ||
onFocus={onFocus} | ||
onKeyDown={onKeyDown} | ||
/> | ||
{dropdownVisible && (autocompleteTagResults.length > 0 || autocompleteTagHintVisible) && ( | ||
<DisclosurePanel | ||
ref={dropdownRef} | ||
className={`${tags.length > 0 ? 'w-80' : 'w-70 mr-10'} sn-dropdown flex flex-col py-2 absolute`} | ||
style={{ maxHeight: dropdownMaxHeight, maxWidth: tagsContainerMaxWidth }} | ||
> | ||
<div className="overflow-y-scroll"> | ||
{autocompleteTagResults.map((tagResult) => ( | ||
<AutocompleteTagResult | ||
key={tagResult.uuid} | ||
appState={appState} | ||
tagResult={tagResult} | ||
closeOnBlur={closeOnBlur} | ||
/> | ||
))} | ||
</div> | ||
{autocompleteTagHintVisible && ( | ||
<AutocompleteTagHint | ||
appState={appState} | ||
closeOnBlur={closeOnBlur} | ||
/> | ||
)} | ||
</DisclosurePanel> | ||
)} | ||
</Disclosure> | ||
</form> | ||
); | ||
}); |
109 changes: 109 additions & 0 deletions
109
app/assets/javascripts/components/AutocompleteTagResult.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { AppState } from '@/ui_models/app_state'; | ||
import { SNTag } from '@standardnotes/snjs'; | ||
import { observer } from 'mobx-react-lite'; | ||
import { useEffect, useRef } from 'preact/hooks'; | ||
import { Icon } from './Icon'; | ||
|
||
type Props = { | ||
appState: AppState; | ||
tagResult: SNTag; | ||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; | ||
}; | ||
|
||
export const AutocompleteTagResult = observer( | ||
({ appState, tagResult, closeOnBlur }: Props) => { | ||
const { | ||
autocompleteSearchQuery, | ||
autocompleteTagHintVisible, | ||
autocompleteTagResults, | ||
focusedTagResultUuid, | ||
} = appState.noteTags; | ||
|
||
const tagResultRef = useRef<HTMLButtonElement>(); | ||
|
||
const onTagOptionClick = async (tag: SNTag) => { | ||
await appState.noteTags.addTagToActiveNote(tag); | ||
appState.noteTags.clearAutocompleteSearch(); | ||
appState.noteTags.setAutocompleteInputFocused(true); | ||
}; | ||
|
||
const onKeyDown = (event: KeyboardEvent) => { | ||
const tagResultIndex = appState.noteTags.getTagIndex( | ||
tagResult, | ||
autocompleteTagResults | ||
); | ||
switch (event.key) { | ||
case 'ArrowUp': | ||
event.preventDefault(); | ||
if (tagResultIndex === 0) { | ||
appState.noteTags.setAutocompleteInputFocused(true); | ||
} else { | ||
appState.noteTags.focusPreviousTagResult(tagResult); | ||
} | ||
break; | ||
case 'ArrowDown': | ||
event.preventDefault(); | ||
if ( | ||
tagResultIndex === autocompleteTagResults.length - 1 && | ||
autocompleteTagHintVisible | ||
) { | ||
appState.noteTags.setAutocompleteTagHintFocused(true); | ||
} else { | ||
appState.noteTags.focusNextTagResult(tagResult); | ||
} | ||
break; | ||
default: | ||
return; | ||
} | ||
}; | ||
|
||
const onFocus = () => { | ||
appState.noteTags.setFocusedTagResultUuid(tagResult.uuid); | ||
}; | ||
|
||
const onBlur = (event: FocusEvent) => { | ||
closeOnBlur(event); | ||
appState.noteTags.setFocusedTagResultUuid(undefined); | ||
}; | ||
|
||
useEffect(() => { | ||
if (focusedTagResultUuid === tagResult.uuid) { | ||
tagResultRef.current.focus(); | ||
appState.noteTags.setFocusedTagResultUuid(undefined); | ||
} | ||
}, [appState.noteTags, focusedTagResultUuid, tagResult]); | ||
|
||
return ( | ||
<button | ||
ref={tagResultRef} | ||
type="button" | ||
className="sn-dropdown-item" | ||
onClick={() => onTagOptionClick(tagResult)} | ||
onFocus={onFocus} | ||
onBlur={onBlur} | ||
onKeyDown={onKeyDown} | ||
> | ||
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" /> | ||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis"> | ||
{autocompleteSearchQuery === '' | ||
? tagResult.title | ||
: tagResult.title | ||
.split(new RegExp(`(${autocompleteSearchQuery})`, 'gi')) | ||
.map((substring, index) => ( | ||
<span | ||
key={index} | ||
className={`${ | ||
substring.toLowerCase() === | ||
autocompleteSearchQuery.toLowerCase() | ||
? 'font-bold whitespace-pre-wrap' | ||
: 'whitespace-pre-wrap ' | ||
}`} | ||
> | ||
{substring} | ||
</span> | ||
))} | ||
</span> | ||
</button> | ||
); | ||
} | ||
); |
Oops, something went wrong.