feat: add autocomplete tags input and dropdown
This commit is contained in:
91
app/assets/javascripts/components/AutocompleteTagInput.tsx
Normal file
91
app/assets/javascripts/components/AutocompleteTagInput.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { WebApplication } from '@/ui_models/application';
|
||||||
|
import { SNTag } from '@standardnotes/snjs';
|
||||||
|
import { FunctionalComponent } from 'preact';
|
||||||
|
import { useRef, useState } from 'preact/hooks';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import { Disclosure, DisclosurePanel } from '@reach/disclosure';
|
||||||
|
import { useCloseOnBlur } from './utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AutocompleteTagInput: FunctionalComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [tagResults, setTagResults] = useState<SNTag[]>(() => {
|
||||||
|
return application.searchTags('');
|
||||||
|
});
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>();
|
||||||
|
const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) =>
|
||||||
|
setDropdownVisible(visible)
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSearchQueryChange = (event: Event) => {
|
||||||
|
const query = (event.target as HTMLInputElement).value;
|
||||||
|
const tags = application.searchTags(query);
|
||||||
|
|
||||||
|
setSearchQuery(query);
|
||||||
|
setTagResults(tags);
|
||||||
|
setDropdownVisible(tags.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(event) => event.preventDefault()} className="mt-2">
|
||||||
|
<Disclosure
|
||||||
|
open={dropdownVisible}
|
||||||
|
onChange={() => setDropdownVisible(true)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="min-w-80 text-xs no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchQueryChange}
|
||||||
|
type="text"
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
onFocus={() => {
|
||||||
|
if (tagResults.length > 0) {
|
||||||
|
setDropdownVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{dropdownVisible && (
|
||||||
|
<DisclosurePanel
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="sn-dropdown flex flex-col py-2 max-h-120 overflow-y-scroll absolute"
|
||||||
|
>
|
||||||
|
{tagResults.map((tag) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.uuid}
|
||||||
|
className={`flex items-center border-0 focus:inner-ring-info cursor-pointer
|
||||||
|
hover:bg-contrast color-text bg-transparent px-3 text-left py-1.5`}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<Icon type="hashtag" className="color-neutral mr-2" />
|
||||||
|
{tag.title
|
||||||
|
.toLowerCase()
|
||||||
|
.split(new RegExp(`(${searchQuery})`, 'gi'))
|
||||||
|
.map((substring, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={
|
||||||
|
substring?.toLowerCase() === searchQuery.toLowerCase()
|
||||||
|
? 'font-bold whitespace-pre-wrap'
|
||||||
|
: 'whitespace-pre-wrap'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{substring}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DisclosurePanel>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
import { AppState } from "@/ui_models/app_state";
|
import { AppState } from '@/ui_models/app_state';
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from 'mobx-react-lite';
|
||||||
import { toDirective } from "./utils";
|
import { toDirective } from './utils';
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from './Icon';
|
||||||
|
import { AutocompleteTagInput } from './AutocompleteTagInput';
|
||||||
|
import { WebApplication } from '@/ui_models/application';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
application: WebApplication;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
}
|
};
|
||||||
|
|
||||||
const NoteTags = observer(({ appState }: Props) => {
|
const NoteTags = observer(({ application, appState }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex mt-2">
|
<div className="flex flex-wrap">
|
||||||
{appState.notes.activeNoteTags.map(tag => (
|
{appState.notes.activeNoteTags.map((tag) => (
|
||||||
<span className="bg-contrast rounded text-sm color-text p-1 flex items-center mr-2">
|
<span className="bg-contrast rounded text-xs color-text p-1 flex items-center mt-2 mr-2">
|
||||||
<Icon type="hashtag" className="small color-neutral mr-1" />
|
<Icon type="hashtag" className="small color-neutral mr-1" />
|
||||||
{tag.title}
|
{tag.title}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
<AutocompleteTagInput application={application} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NoteTagsDirective = toDirective<Props>(NoteTags);
|
export const NoteTagsDirective = toDirective<Props>(NoteTags);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
)
|
)
|
||||||
div.flex
|
div.flex
|
||||||
note-tags(
|
note-tags(
|
||||||
|
application='self.application'
|
||||||
app-state='self.appState'
|
app-state='self.appState'
|
||||||
)
|
)
|
||||||
input.tags-input(
|
input.tags-input(
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
border-color: var(--sn-stylekit-background-color);
|
border-color: var(--sn-stylekit-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focus\:border-bottom:focus {
|
||||||
|
border-bottom: 2px solid var(--sn-stylekit-info-color);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -155,6 +159,10 @@
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-7 {
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-8 {
|
.h-8 {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
@@ -187,6 +195,14 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A button that is just an icon. Separated from .sn-button because there
|
* A button that is just an icon. Separated from .sn-button because there
|
||||||
* is almost no style overlap.
|
* is almost no style overlap.
|
||||||
|
|||||||
Reference in New Issue
Block a user