feat: add arrow key navigation for results dropdown
This commit is contained in:
@@ -15,6 +15,8 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
autocompleteSearchQuery,
|
autocompleteSearchQuery,
|
||||||
autocompleteTagHintVisible,
|
autocompleteTagHintVisible,
|
||||||
autocompleteTagResults,
|
autocompleteTagResults,
|
||||||
|
autocompleteTagResultElements,
|
||||||
|
autocompleteInputElement,
|
||||||
tagElements,
|
tagElements,
|
||||||
tags,
|
tags,
|
||||||
} = appState.noteTags;
|
} = appState.noteTags;
|
||||||
@@ -23,7 +25,6 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
const [dropdownMaxHeight, setDropdownMaxHeight] =
|
const [dropdownMaxHeight, setDropdownMaxHeight] =
|
||||||
useState<number | 'auto'>('auto');
|
useState<number | 'auto'>('auto');
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>();
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>();
|
const dropdownRef = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => {
|
const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => {
|
||||||
@@ -32,9 +33,11 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showDropdown = () => {
|
const showDropdown = () => {
|
||||||
const { clientHeight } = document.documentElement;
|
if (autocompleteInputElement) {
|
||||||
const inputRect = inputRef.current.getBoundingClientRect();
|
const { clientHeight } = document.documentElement;
|
||||||
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
|
const inputRect = autocompleteInputElement.getBoundingClientRect();
|
||||||
|
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
|
||||||
|
}
|
||||||
setDropdownVisible(true);
|
setDropdownVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +52,24 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
await appState.noteTags.createAndAddNewTag();
|
await appState.noteTags.createAndAddNewTag();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Backspace':
|
||||||
|
if (autocompleteSearchQuery === '' && tagElements.length > 0) {
|
||||||
|
tagElements[tagElements.length - 1]?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
if (autocompleteTagResultElements.length > 0) {
|
||||||
|
autocompleteTagResultElements[0]?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
appState.noteTags.searchActiveNoteAutocompleteTags();
|
appState.noteTags.searchActiveNoteAutocompleteTags();
|
||||||
}, [appState.noteTags]);
|
}, [appState.noteTags]);
|
||||||
@@ -60,7 +81,11 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
>
|
>
|
||||||
<Disclosure open={dropdownVisible} onChange={showDropdown}>
|
<Disclosure open={dropdownVisible} onChange={showDropdown}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={(element) => {
|
||||||
|
if (element) {
|
||||||
|
appState.noteTags.setAutocompleteInputElement(element);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-80 bg-default text-xs color-text no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom"
|
className="w-80 bg-default text-xs color-text no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom"
|
||||||
value={autocompleteSearchQuery}
|
value={autocompleteSearchQuery}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
@@ -68,15 +93,7 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
|
|||||||
placeholder="Add tag"
|
placeholder="Add tag"
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
onFocus={showDropdown}
|
onFocus={showDropdown}
|
||||||
onKeyUp={(event) => {
|
onKeyDown={onKeyDown}
|
||||||
if (
|
|
||||||
event.key === 'Backspace' &&
|
|
||||||
autocompleteSearchQuery === '' &&
|
|
||||||
tagElements.length > 0
|
|
||||||
) {
|
|
||||||
tagElements[tagElements.length - 1]?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{dropdownVisible && (
|
{dropdownVisible && (
|
||||||
<DisclosurePanel
|
<DisclosurePanel
|
||||||
|
|||||||
@@ -11,13 +11,33 @@ type Props = {
|
|||||||
|
|
||||||
export const AutocompleteTagResult = observer(
|
export const AutocompleteTagResult = observer(
|
||||||
({ appState, tagResult, closeOnBlur }: Props) => {
|
({ appState, tagResult, closeOnBlur }: Props) => {
|
||||||
const { autocompleteSearchQuery } = appState.noteTags;
|
const { autocompleteInputElement, autocompleteSearchQuery, autocompleteTagResults } = appState.noteTags;
|
||||||
|
|
||||||
const onTagOptionClick = async (tag: SNTag) => {
|
const onTagOptionClick = async (tag: SNTag) => {
|
||||||
await appState.noteTags.addTagToActiveNote(tag);
|
await appState.noteTags.addTagToActiveNote(tag);
|
||||||
appState.noteTags.clearAutocompleteSearch();
|
appState.noteTags.clearAutocompleteSearch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const tagResultIndex = appState.noteTags.getTagIndex(tagResult, autocompleteTagResults);
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
if (tagResultIndex === 0) {
|
||||||
|
autocompleteInputElement?.focus();
|
||||||
|
} else {
|
||||||
|
appState.noteTags.getPreviousAutocompleteTagResultElement(tagResult)?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
appState.noteTags.getNextAutocompleteTagResultElement(tagResult)?.focus();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
@@ -32,6 +52,7 @@ export const AutocompleteTagResult = observer(
|
|||||||
className="sn-dropdown-item"
|
className="sn-dropdown-item"
|
||||||
onClick={() => onTagOptionClick(tagResult)}
|
onClick={() => onTagOptionClick(tagResult)}
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
>
|
>
|
||||||
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
|
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
|
||||||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
|
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||||
|
|||||||
@@ -34,21 +34,16 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
let previousTagElement;
|
|
||||||
let nextTagElement;
|
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
deleteTag();
|
deleteTag();
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
previousTagElement = appState.noteTags.getPreviousTagElement(tag);
|
appState.noteTags.getPreviousTagElement(tag)?.focus();
|
||||||
previousTagElement?.focus();
|
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
nextTagElement = appState.noteTags.getNextTagElement(tag);
|
appState.noteTags.getNextTagElement(tag)?.focus();
|
||||||
nextTagElement?.focus();
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
@@ -65,7 +60,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
|||||||
className="sn-tag pl-1 pr-2 mr-2"
|
className="sn-tag pl-1 pr-2 mr-2"
|
||||||
style={{ maxWidth: tagsContainerMaxWidth }}
|
style={{ maxWidth: tagsContainerMaxWidth }}
|
||||||
onClick={onTagClick}
|
onClick={onTagClick}
|
||||||
onKeyUp={onKeyUp}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const NotesOptions = observer(
|
|||||||
{appState.tags.tagsCount > 0 && (
|
{appState.tags.tagsCount > 0 && (
|
||||||
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
|
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
|
||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
onKeyUp={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setTagsMenuOpen(false);
|
setTagsMenuOpen(false);
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ export const NotesOptions = observer(
|
|||||||
<Icon type="chevron-right" className="color-neutral" />
|
<Icon type="chevron-right" className="color-neutral" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel
|
<DisclosurePanel
|
||||||
onKeyUp={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setTagsMenuOpen(false);
|
setTagsMenuOpen(false);
|
||||||
tagsButtonRef.current.focus();
|
tagsButtonRef.current.focus();
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
onKeyUp={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape' && !submenuOpen) {
|
if (event.key === 'Escape' && !submenuOpen) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
|
|||||||
<Icon type="more" className="block" />
|
<Icon type="more" className="block" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel
|
<DisclosurePanel
|
||||||
onKeyUp={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape' && !submenuOpen) {
|
if (event.key === 'Escape' && !submenuOpen) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
buttonRef.current.focus();
|
buttonRef.current.focus();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WebApplication } from '../application';
|
|||||||
import { AppState } from './app_state';
|
import { AppState } from './app_state';
|
||||||
|
|
||||||
export class NoteTagsState {
|
export class NoteTagsState {
|
||||||
|
autocompleteInputElement: HTMLInputElement | undefined = undefined;
|
||||||
autocompleteSearchQuery = '';
|
autocompleteSearchQuery = '';
|
||||||
autocompleteTagResultElements: (HTMLButtonElement | undefined)[] = [];
|
autocompleteTagResultElements: (HTMLButtonElement | undefined)[] = [];
|
||||||
autocompleteTagResults: SNTag[] = [];
|
autocompleteTagResults: SNTag[] = [];
|
||||||
@@ -17,6 +18,7 @@ export class NoteTagsState {
|
|||||||
appEventListeners: (() => void)[]
|
appEventListeners: (() => void)[]
|
||||||
) {
|
) {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
|
autocompleteInputElement: observable,
|
||||||
autocompleteSearchQuery: observable,
|
autocompleteSearchQuery: observable,
|
||||||
autocompleteTagResultElements: observable,
|
autocompleteTagResultElements: observable,
|
||||||
autocompleteTagResults: observable,
|
autocompleteTagResults: observable,
|
||||||
@@ -27,6 +29,7 @@ export class NoteTagsState {
|
|||||||
autocompleteTagHintVisible: computed,
|
autocompleteTagHintVisible: computed,
|
||||||
|
|
||||||
clearAutocompleteSearch: action,
|
clearAutocompleteSearch: action,
|
||||||
|
setAutocompleteInputElement: action,
|
||||||
setAutocompleteSearchQuery: action,
|
setAutocompleteSearchQuery: action,
|
||||||
setAutocompleteTagResultElement: action,
|
setAutocompleteTagResultElement: action,
|
||||||
setAutocompleteTagResultElements: action,
|
setAutocompleteTagResultElements: action,
|
||||||
@@ -58,6 +61,10 @@ export class NoteTagsState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAutocompleteInputElement(element: HTMLInputElement | undefined): void {
|
||||||
|
this.autocompleteInputElement = element;
|
||||||
|
}
|
||||||
|
|
||||||
setAutocompleteSearchQuery(query: string): void {
|
setAutocompleteSearchQuery(query: string): void {
|
||||||
this.autocompleteSearchQuery = query;
|
this.autocompleteSearchQuery = query;
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,9 @@ export class NoteTagsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createAndAddNewTag(): Promise<void> {
|
async createAndAddNewTag(): Promise<void> {
|
||||||
const newTag = await this.application.findOrCreateTag(this.autocompleteSearchQuery);
|
const newTag = await this.application.findOrCreateTag(
|
||||||
|
this.autocompleteSearchQuery
|
||||||
|
);
|
||||||
await this.addTagToActiveNote(newTag);
|
await this.addTagToActiveNote(newTag);
|
||||||
this.clearAutocompleteSearch();
|
this.clearAutocompleteSearch();
|
||||||
}
|
}
|
||||||
@@ -138,6 +147,32 @@ export class NoteTagsState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreviousAutocompleteTagResultElement(
|
||||||
|
tagResult: SNTag
|
||||||
|
): HTMLButtonElement | undefined {
|
||||||
|
const previousTagIndex =
|
||||||
|
this.getTagIndex(tagResult, this.autocompleteTagResults) - 1;
|
||||||
|
if (
|
||||||
|
previousTagIndex > -1 &&
|
||||||
|
this.autocompleteTagResultElements.length > previousTagIndex
|
||||||
|
) {
|
||||||
|
return this.autocompleteTagResultElements[previousTagIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextAutocompleteTagResultElement(
|
||||||
|
tagResult: SNTag
|
||||||
|
): HTMLButtonElement | undefined {
|
||||||
|
const nextTagIndex =
|
||||||
|
this.getTagIndex(tagResult, this.autocompleteTagResults) + 1;
|
||||||
|
if (
|
||||||
|
nextTagIndex > -1 &&
|
||||||
|
this.autocompleteTagResultElements.length > nextTagIndex
|
||||||
|
) {
|
||||||
|
return this.autocompleteTagResultElements[nextTagIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reloadTags(): void {
|
reloadTags(): void {
|
||||||
const { activeNote } = this;
|
const { activeNote } = this;
|
||||||
if (activeNote) {
|
if (activeNote) {
|
||||||
|
|||||||
Reference in New Issue
Block a user