refactor: store refs in components

This commit is contained in:
Antonella Sgarlatta
2021-06-03 17:45:43 -03:00
parent 3d0c8d5cce
commit 2b40ccfe13
4 changed files with 139 additions and 121 deletions

View File

@@ -12,12 +12,10 @@ type Props = {
export const AutocompleteTagInput = observer(({ appState }: Props) => { export const AutocompleteTagInput = observer(({ appState }: Props) => {
const { const {
autocompleteInputFocused,
autocompleteSearchQuery, autocompleteSearchQuery,
autocompleteTagHintVisible, autocompleteTagHintVisible,
autocompleteTagResults, autocompleteTagResults,
autocompleteTagResultElements,
autocompleteInputElement,
tagElements,
tags, tags,
} = appState.noteTags; } = appState.noteTags;
@@ -26,6 +24,7 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
useState<number | 'auto'>('auto'); useState<number | 'auto'>('auto');
const dropdownRef = useRef<HTMLDivElement>(); const dropdownRef = useRef<HTMLDivElement>();
const inputRef = useRef<HTMLInputElement>();
const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => { const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => {
setDropdownVisible(visible); setDropdownVisible(visible);
@@ -33,11 +32,9 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
}); });
const showDropdown = () => { const showDropdown = () => {
if (autocompleteInputElement) { const { clientHeight } = document.documentElement;
const { clientHeight } = document.documentElement; const inputRect = inputRef.current.getBoundingClientRect();
const inputRect = autocompleteInputElement.getBoundingClientRect(); setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
}
setDropdownVisible(true); setDropdownVisible(true);
}; };
@@ -55,14 +52,15 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
switch (event.key) { switch (event.key) {
case 'Backspace': case 'Backspace':
if (autocompleteSearchQuery === '' && tagElements.length > 0) { case 'ArrowLeft':
tagElements[tagElements.length - 1]?.focus(); if (autocompleteSearchQuery === '' && tags.length > 0) {
appState.noteTags.setFocusedTagUuid(tags[tags.length - 1].uuid);
} }
break; break;
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); event.preventDefault();
if (autocompleteTagResultElements.length > 0) { if (autocompleteTagResults.length > 0) {
autocompleteTagResultElements[0]?.focus(); appState.noteTags.setFocusedTagResultUuid(autocompleteTagResults[0].uuid);
} }
break; break;
default: default:
@@ -70,10 +68,27 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
} }
}; };
const onFocus = () => {
showDropdown();
appState.noteTags.setAutocompleteInputFocused(true);
};
const onBlur = (event: FocusEvent) => {
closeOnBlur(event);
appState.noteTags.setAutocompleteInputFocused(false);
};
useEffect(() => { useEffect(() => {
appState.noteTags.searchActiveNoteAutocompleteTags(); appState.noteTags.searchActiveNoteAutocompleteTags();
}, [appState.noteTags]); }, [appState.noteTags]);
useEffect(() => {
if (autocompleteInputFocused) {
inputRef.current.focus();
appState.noteTags.setAutocompleteInputFocused(false);
}
}, [appState.noteTags, autocompleteInputFocused]);
return ( return (
<form <form
onSubmit={onFormSubmit} onSubmit={onFormSubmit}
@@ -81,18 +96,14 @@ export const AutocompleteTagInput = observer(({ appState }: Props) => {
> >
<Disclosure open={dropdownVisible} onChange={showDropdown}> <Disclosure open={dropdownVisible} onChange={showDropdown}>
<input <input
ref={(element) => { ref={inputRef}
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}
type="text" type="text"
placeholder="Add tag" placeholder="Add tag"
onBlur={closeOnBlur} onBlur={onBlur}
onFocus={showDropdown} onFocus={onFocus}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{dropdownVisible && ( {dropdownVisible && (

View File

@@ -1,6 +1,7 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { SNTag } from '@standardnotes/snjs'; import { SNTag } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useEffect, useRef } from 'preact/hooks';
import { Icon } from './Icon'; import { Icon } from './Icon';
type Props = { type Props = {
@@ -11,7 +12,9 @@ type Props = {
export const AutocompleteTagResult = observer( export const AutocompleteTagResult = observer(
({ appState, tagResult, closeOnBlur }: Props) => { ({ appState, tagResult, closeOnBlur }: Props) => {
const { autocompleteInputElement, autocompleteSearchQuery, autocompleteTagResults } = appState.noteTags; const { autocompleteSearchQuery, autocompleteTagResults, focusedTagResultUuid } = appState.noteTags;
const tagResultRef = useRef<HTMLButtonElement>();
const onTagOptionClick = async (tag: SNTag) => { const onTagOptionClick = async (tag: SNTag) => {
await appState.noteTags.addTagToActiveNote(tag); await appState.noteTags.addTagToActiveNote(tag);
@@ -24,34 +27,44 @@ export const AutocompleteTagResult = observer(
case 'ArrowUp': case 'ArrowUp':
event.preventDefault(); event.preventDefault();
if (tagResultIndex === 0) { if (tagResultIndex === 0) {
autocompleteInputElement?.focus(); appState.noteTags.setAutocompleteInputFocused(true);
} else { } else {
appState.noteTags.getPreviousAutocompleteTagResultElement(tagResult)?.focus(); appState.noteTags.focusPreviousTagResult(tagResult);
} }
break; break;
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); event.preventDefault();
appState.noteTags.getNextAutocompleteTagResultElement(tagResult)?.focus(); appState.noteTags.focusNextTagResult(tagResult);
break; break;
default: default:
return; 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 ( return (
<button <button
ref={(element) => { ref={tagResultRef}
if (element) {
appState.noteTags.setAutocompleteTagResultElement(
tagResult,
element
);
}
}}
type="button" type="button"
className="sn-dropdown-item" className="sn-dropdown-item"
onClick={() => onTagOptionClick(tagResult)} onClick={() => onTagOptionClick(tagResult)}
onBlur={closeOnBlur} onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown} 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" />

View File

@@ -1,5 +1,5 @@
import { Icon } from './Icon'; import { Icon } from './Icon';
import { useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { SNTag } from '@standardnotes/snjs/dist/@types'; import { SNTag } from '@standardnotes/snjs/dist/@types';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@@ -10,11 +10,16 @@ type Props = {
}; };
export const NoteTag = observer(({ appState, tag }: Props) => { export const NoteTag = observer(({ appState, tag }: Props) => {
const { focusedTagUuid, tags } = appState.noteTags;
const [showDeleteButton, setShowDeleteButton] = useState(false); const [showDeleteButton, setShowDeleteButton] = useState(false);
const [tagClicked, setTagClicked] = useState(false); const [tagClicked, setTagClicked] = useState(false);
const deleteTagRef = useRef<HTMLButtonElement>(); const deleteTagRef = useRef<HTMLButtonElement>();
const tagRef = useRef<HTMLButtonElement>();
const deleteTag = () => { const deleteTag = () => {
appState.noteTags.focusPreviousTag(tag);
appState.noteTags.removeTagFromActiveNote(tag); appState.noteTags.removeTagFromActiveNote(tag);
}; };
@@ -34,39 +39,49 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
}; };
const onFocus = () => { const onFocus = () => {
appState.noteTags.setFocusedTagUuid(tag.uuid);
setShowDeleteButton(true); setShowDeleteButton(true);
}; };
const onBlur = (event: FocusEvent) => { const onBlur = (event: FocusEvent) => {
const relatedTarget = event.relatedTarget as Node; const relatedTarget = event.relatedTarget as Node;
if (relatedTarget !== deleteTagRef.current) { if (relatedTarget !== deleteTagRef.current) {
appState.noteTags.setFocusedTagUuid(undefined);
setShowDeleteButton(false); setShowDeleteButton(false);
} }
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const tagIndex = appState.noteTags.getTagIndex(tag, tags);
switch (event.key) { switch (event.key) {
case 'Backspace': case 'Backspace':
deleteTag(); deleteTag();
break; break;
case 'ArrowLeft': case 'ArrowLeft':
appState.noteTags.getPreviousTagElement(tag)?.focus(); appState.noteTags.focusPreviousTag(tag);
break; break;
case 'ArrowRight': case 'ArrowRight':
appState.noteTags.getNextTagElement(tag)?.focus(); if (tagIndex === tags.length - 1) {
appState.noteTags.setAutocompleteInputFocused(true);
} else {
appState.noteTags.focusNextTag(tag);
}
break; break;
default: default:
return; return;
} }
}; };
useEffect(() => {
if (focusedTagUuid === tag.uuid) {
tagRef.current.focus();
appState.noteTags.setFocusedTagUuid(undefined);
}
}, [appState.noteTags, focusedTagUuid, tag]);
return ( return (
<button <button
ref={(element) => { ref={tagRef}
if (element) {
appState.noteTags.setTagElement(tag, element);
}
}}
className="sn-tag pl-1 pr-2 mr-2" className="sn-tag pl-1 pr-2 mr-2"
onClick={onTagClick} onClick={onTagClick}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}

View File

@@ -1,14 +1,14 @@
import { SNNote, ContentType, SNTag } from '@standardnotes/snjs'; import { SNNote, ContentType, SNTag, UuidString } from '@standardnotes/snjs';
import { action, computed, makeObservable, observable } from 'mobx'; import { action, computed, makeObservable, observable } from 'mobx';
import { WebApplication } from '../application'; import { WebApplication } from '../application';
import { AppState } from './app_state'; import { AppState } from './app_state';
export class NoteTagsState { export class NoteTagsState {
autocompleteInputElement: HTMLInputElement | undefined = undefined; autocompleteInputFocused = false;
autocompleteSearchQuery = ''; autocompleteSearchQuery = '';
autocompleteTagResultElements: (HTMLButtonElement | undefined)[] = [];
autocompleteTagResults: SNTag[] = []; autocompleteTagResults: SNTag[] = [];
tagElements: (HTMLButtonElement | undefined)[] = []; focusedTagResultUuid: UuidString | undefined = undefined;
focusedTagUuid: UuidString | undefined = undefined;
tags: SNTag[] = []; tags: SNTag[] = [];
tagsContainerMaxWidth: number | 'auto' = 0; tagsContainerMaxWidth: number | 'auto' = 0;
@@ -18,24 +18,24 @@ export class NoteTagsState {
appEventListeners: (() => void)[] appEventListeners: (() => void)[]
) { ) {
makeObservable(this, { makeObservable(this, {
autocompleteInputElement: observable, autocompleteInputFocused: observable,
autocompleteSearchQuery: observable, autocompleteSearchQuery: observable,
autocompleteTagResultElements: observable,
autocompleteTagResults: observable, autocompleteTagResults: observable,
tagElements: observable, focusedTagUuid: observable,
focusedTagResultUuid: observable,
tags: observable, tags: observable,
tagsContainerMaxWidth: observable, tagsContainerMaxWidth: observable,
autocompleteTagHintVisible: computed, autocompleteTagHintVisible: computed,
clearAutocompleteSearch: action, clearAutocompleteSearch: action,
setAutocompleteInputElement: action, focusNextTag: action,
focusPreviousTag: action,
setAutocompleteInputFocused: action,
setAutocompleteSearchQuery: action, setAutocompleteSearchQuery: action,
setAutocompleteTagResultElement: action,
setAutocompleteTagResultElements: action,
setAutocompleteTagResults: action, setAutocompleteTagResults: action,
setTagElement: action, setFocusedTagResultUuid: action,
setTagElements: action, setFocusedTagUuid: action,
setTags: action, setTags: action,
setTagsContainerMaxWidth: action, setTagsContainerMaxWidth: action,
reloadTags: action, reloadTags: action,
@@ -61,43 +61,25 @@ export class NoteTagsState {
); );
} }
setAutocompleteInputElement(element: HTMLInputElement | undefined): void { setAutocompleteInputFocused(focused: boolean): void {
this.autocompleteInputElement = element; console.log('set focused', focused);
this.autocompleteInputFocused = focused;
} }
setAutocompleteSearchQuery(query: string): void { setAutocompleteSearchQuery(query: string): void {
this.autocompleteSearchQuery = query; this.autocompleteSearchQuery = query;
} }
setAutocompleteTagResultElement(
tagResult: SNTag,
element: HTMLButtonElement
): void {
const tagIndex = this.getTagIndex(tagResult, this.autocompleteTagResults);
if (tagIndex > -1) {
this.autocompleteTagResultElements.splice(tagIndex, 1, element);
}
}
setAutocompleteTagResultElements(
elements: (HTMLButtonElement | undefined)[]
): void {
this.autocompleteTagResultElements = elements;
}
setAutocompleteTagResults(results: SNTag[]): void { setAutocompleteTagResults(results: SNTag[]): void {
this.autocompleteTagResults = results; this.autocompleteTagResults = results;
} }
setTagElement(tag: SNTag, element: HTMLButtonElement): void { setFocusedTagUuid(tagUuid: UuidString | undefined): void {
const tagIndex = this.getTagIndex(tag, this.tags); this.focusedTagUuid = tagUuid;
if (tagIndex > -1) {
this.tagElements.splice(tagIndex, 1, element);
}
} }
setTagElements(elements: (HTMLButtonElement | undefined)[]): void { setFocusedTagResultUuid(tagUuid: UuidString | undefined): void {
this.tagElements = elements; this.focusedTagResultUuid = tagUuid;
} }
setTags(tags: SNTag[]): void { setTags(tags: SNTag[]): void {
@@ -121,6 +103,47 @@ export class NoteTagsState {
this.clearAutocompleteSearch(); this.clearAutocompleteSearch();
} }
focusNextTag(tag: SNTag): void {
const nextTagIndex = this.getTagIndex(tag, this.tags) + 1;
if (nextTagIndex > -1 && this.tags.length > nextTagIndex) {
const nextTag = this.tags[nextTagIndex];
this.setFocusedTagUuid(nextTag.uuid);
}
}
focusNextTagResult(tagResult: SNTag): void {
const nextTagResultIndex =
this.getTagIndex(tagResult, this.autocompleteTagResults) + 1;
if (
nextTagResultIndex > -1 &&
this.autocompleteTagResults.length > nextTagResultIndex
) {
const nextTagResult = this.autocompleteTagResults[nextTagResultIndex];
this.setFocusedTagResultUuid(nextTagResult.uuid);
}
}
focusPreviousTag(tag: SNTag): void {
const previousTagIndex = this.getTagIndex(tag, this.tags) - 1;
if (previousTagIndex > -1 && this.tags.length > previousTagIndex) {
const previousTag = this.tags[previousTagIndex];
this.setFocusedTagUuid(previousTag.uuid);
}
}
focusPreviousTagResult(tagResult: SNTag): void {
const previousTagResultIndex =
this.getTagIndex(tagResult, this.autocompleteTagResults) - 1;
if (
previousTagResultIndex > -1 &&
this.autocompleteTagResults.length > previousTagResultIndex
) {
const previousTagResult =
this.autocompleteTagResults[previousTagResultIndex];
this.setFocusedTagResultUuid(previousTagResult.uuid);
}
}
searchActiveNoteAutocompleteTags(): void { searchActiveNoteAutocompleteTags(): void {
const newResults = this.application.searchTags( const newResults = this.application.searchTags(
this.autocompleteSearchQuery, this.autocompleteSearchQuery,
@@ -133,53 +156,11 @@ export class NoteTagsState {
return tagsArr.findIndex((t) => t.uuid === tag.uuid); return tagsArr.findIndex((t) => t.uuid === tag.uuid);
} }
getPreviousTagElement(tag: SNTag): HTMLButtonElement | undefined {
const previousTagIndex = this.getTagIndex(tag, this.tags) - 1;
if (previousTagIndex > -1 && this.tagElements.length > previousTagIndex) {
return this.tagElements[previousTagIndex];
}
}
getNextTagElement(tag: SNTag): HTMLButtonElement | undefined {
const nextTagIndex = this.getTagIndex(tag, this.tags) + 1;
if (nextTagIndex > -1 && this.tagElements.length > nextTagIndex) {
return this.tagElements[nextTagIndex];
}
}
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) {
const tags = this.application.getSortedTagsForNote(activeNote); const tags = this.application.getSortedTagsForNote(activeNote);
const tagElements: (HTMLButtonElement | undefined)[] = [];
this.setTags(tags); this.setTags(tags);
this.setTagElements(tagElements.fill(undefined, tags.length));
} }
} }
@@ -205,12 +186,10 @@ export class NoteTagsState {
async removeTagFromActiveNote(tag: SNTag): Promise<void> { async removeTagFromActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this; const { activeNote } = this;
if (activeNote) { if (activeNote) {
const previousTagElement = this.getPreviousTagElement(tag);
await this.application.changeItem(tag.uuid, (mutator) => { await this.application.changeItem(tag.uuid, (mutator) => {
mutator.removeItemAsRelationship(activeNote); mutator.removeItemAsRelationship(activeNote);
}); });
this.application.sync(); this.application.sync();
previousTagElement?.focus();
this.reloadTags(); this.reloadTags();
} }
} }