feat: redesign search filtering experience (#908)
* feat(search): redesign search filters as bubbles like mobile * fix(search): decouble Bubble component styling - animate the bubles on search - decouple the Bubbles styling using utility classes - improve styling of the new search options * fix(Bubble): remove duplicated utility classes * fix(bubble): use color neutral utility * fix(bubble): increase gaps and justify center * fix(Bubble): increase height and decrease gap * fix(search): improve usability on search options - increase animation timing to match mobile - properly center cancel button - only show cancel on text input - prevent search options from disappearing when clicking with no text * fix(search-options): improve spacing and auto size * fix(search-options): improve animation and decrease gap
This commit is contained in:
25
app/assets/javascripts/components/Bubble.tsx
Normal file
25
app/assets/javascripts/components/Bubble.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
interface BubbleProperties {
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast',
|
||||||
|
unselected: 'color-neutral border-secondary',
|
||||||
|
selected: 'border-info bg-info color-neutral-contrast',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => (
|
||||||
|
<span
|
||||||
|
role="tab"
|
||||||
|
className={`bubble ${styles.base} ${
|
||||||
|
selected ? styles.selected : styles.unselected
|
||||||
|
}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Bubble;
|
||||||
@@ -48,13 +48,13 @@ export const NotesView: FunctionComponent<Props> = observer(
|
|||||||
selectPreviousNote,
|
selectPreviousNote,
|
||||||
onFilterEnter,
|
onFilterEnter,
|
||||||
handleFilterTextChanged,
|
handleFilterTextChanged,
|
||||||
onSearchInputBlur,
|
|
||||||
clearFilterText,
|
clearFilterText,
|
||||||
paginate,
|
paginate,
|
||||||
panelWidth,
|
panelWidth,
|
||||||
} = appState.notesView;
|
} = appState.notesView;
|
||||||
|
|
||||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
|
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
|
||||||
|
const [focusedSearch, setFocusedSearch] = useState(false);
|
||||||
|
|
||||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
||||||
displayOptionsMenuRef,
|
displayOptionsMenuRef,
|
||||||
@@ -130,6 +130,9 @@ export const NotesView: FunctionComponent<Props> = observer(
|
|||||||
setNoteFilterText((e.target as HTMLInputElement).value);
|
setNoteFilterText((e.target as HTMLInputElement).value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSearchFocused = () => setFocusedSearch(true);
|
||||||
|
const onSearchBlurred = () => setFocusedSearch(false);
|
||||||
|
|
||||||
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.key === KeyboardKey.Enter) {
|
if (e.key === KeyboardKey.Enter) {
|
||||||
onFilterEnter();
|
onFilterEnter();
|
||||||
@@ -179,32 +182,38 @@ export const NotesView: FunctionComponent<Props> = observer(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-section" role="search">
|
<div className="filter-section" role="search">
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<input
|
||||||
id="search-bar"
|
type="text"
|
||||||
className="filter-bar"
|
id="search-bar"
|
||||||
placeholder="Search"
|
className="filter-bar"
|
||||||
title="Searches notes in the currently selected tag"
|
placeholder="Search"
|
||||||
value={noteFilterText}
|
title="Searches notes in the currently selected tag"
|
||||||
onChange={onNoteFilterTextChange}
|
value={noteFilterText}
|
||||||
onKeyUp={onNoteFilterKeyUp}
|
onChange={onNoteFilterTextChange}
|
||||||
onBlur={() => onSearchInputBlur()}
|
onKeyUp={onNoteFilterKeyUp}
|
||||||
/>
|
onFocus={onSearchFocused}
|
||||||
{noteFilterText ? (
|
onBlur={onSearchBlurred}
|
||||||
<button
|
|
||||||
onClick={clearFilterText}
|
|
||||||
aria-role="button"
|
|
||||||
id="search-clear-button"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="ml-2">
|
|
||||||
<SearchOptions
|
|
||||||
application={application}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
/>
|
||||||
|
{noteFilterText && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilterText}
|
||||||
|
aria-role="button"
|
||||||
|
id="search-clear-button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(focusedSearch || noteFilterText) && (
|
||||||
|
<div className="animate-fade-from-top">
|
||||||
|
<SearchOptions
|
||||||
|
application={application}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NoAccountWarning appState={appState} />
|
<NoAccountWarning appState={appState} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { AppState } from '@/ui_models/app_state';
|
import { AppState } from '@/ui_models/app_state';
|
||||||
import { Icon } from './Icon';
|
|
||||||
import { useCloseOnBlur } from './utils';
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
||||||
import { WebApplication } from '@/ui_models/application';
|
import { WebApplication } from '@/ui_models/application';
|
||||||
import VisuallyHidden from '@reach/visually-hidden';
|
|
||||||
import {
|
|
||||||
Disclosure,
|
|
||||||
DisclosureButton,
|
|
||||||
DisclosurePanel,
|
|
||||||
} from '@reach/disclosure';
|
|
||||||
import { Switch } from './Switch';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import Bubble from './Bubble';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -23,94 +14,33 @@ export const SearchOptions = observer(({ appState }: Props) => {
|
|||||||
const { includeProtectedContents, includeArchived, includeTrashed } =
|
const { includeProtectedContents, includeArchived, includeTrashed } =
|
||||||
searchOptions;
|
searchOptions;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [position, setPosition] = useState({
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
});
|
|
||||||
const [maxWidth, setMaxWidth] = useState<number | 'auto'>('auto');
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(
|
|
||||||
panelRef as any,
|
|
||||||
setOpen
|
|
||||||
);
|
|
||||||
|
|
||||||
async function toggleIncludeProtectedContents() {
|
async function toggleIncludeProtectedContents() {
|
||||||
setLockCloseOnBlur(true);
|
await searchOptions.toggleIncludeProtectedContents();
|
||||||
try {
|
|
||||||
await searchOptions.toggleIncludeProtectedContents();
|
|
||||||
} finally {
|
|
||||||
setLockCloseOnBlur(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWidthAndPosition = () => {
|
|
||||||
const rect = buttonRef.current!.getBoundingClientRect();
|
|
||||||
setMaxWidth(rect.right - 16);
|
|
||||||
setPosition({
|
|
||||||
top: rect.bottom,
|
|
||||||
right: document.body.clientWidth - rect.right,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('resize', updateWidthAndPosition);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', updateWidthAndPosition);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure
|
<div
|
||||||
open={open}
|
role="tablist"
|
||||||
onChange={() => {
|
className="search-options justify-center"
|
||||||
updateWidthAndPosition();
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
setOpen(!open);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DisclosureButton
|
<Bubble
|
||||||
ref={buttonRef}
|
label="Protected Contents"
|
||||||
onBlur={closeOnBlur}
|
selected={includeProtectedContents}
|
||||||
className="border-0 p-0 bg-transparent cursor-pointer color-neutral hover:color-info"
|
onSelect={toggleIncludeProtectedContents}
|
||||||
>
|
/>
|
||||||
<VisuallyHidden>Search options</VisuallyHidden>
|
|
||||||
<Icon type="tune" className="block" />
|
<Bubble
|
||||||
</DisclosureButton>
|
label="Archived"
|
||||||
<DisclosurePanel
|
selected={includeArchived}
|
||||||
ref={panelRef}
|
onSelect={searchOptions.toggleIncludeArchived}
|
||||||
style={{
|
/>
|
||||||
...position,
|
|
||||||
maxWidth,
|
<Bubble
|
||||||
}}
|
label="Trashed"
|
||||||
className="sn-dropdown sn-dropdown--animated w-80 fixed grid gap-2 py-2"
|
selected={includeTrashed}
|
||||||
onBlur={closeOnBlur}
|
onSelect={searchOptions.toggleIncludeTrashed}
|
||||||
>
|
/>
|
||||||
<Switch
|
</div>
|
||||||
className="h-10"
|
|
||||||
checked={includeProtectedContents}
|
|
||||||
onChange={toggleIncludeProtectedContents}
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
>
|
|
||||||
<p className="capitalize">Include protected contents</p>
|
|
||||||
</Switch>
|
|
||||||
<Switch
|
|
||||||
className="h-10"
|
|
||||||
checked={includeArchived}
|
|
||||||
onChange={searchOptions.toggleIncludeArchived}
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
>
|
|
||||||
<p className="capitalize">Include archived notes</p>
|
|
||||||
</Switch>
|
|
||||||
<Switch
|
|
||||||
className="h-10"
|
|
||||||
checked={includeTrashed}
|
|
||||||
onChange={searchOptions.toggleIncludeTrashed}
|
|
||||||
onBlur={closeOnBlur}
|
|
||||||
>
|
|
||||||
<p className="capitalize">Include trashed notes</p>
|
|
||||||
</Switch>
|
|
||||||
</DisclosurePanel>
|
|
||||||
</Disclosure>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -559,10 +559,6 @@ export class NotesViewState {
|
|||||||
this.reloadNotes();
|
this.reloadNotes();
|
||||||
};
|
};
|
||||||
|
|
||||||
onSearchInputBlur = () => {
|
|
||||||
this.appState.searchOptions.refreshIncludeProtectedContents();
|
|
||||||
};
|
|
||||||
|
|
||||||
clearFilterText = () => {
|
clearFilterText = () => {
|
||||||
this.setNoteFilterText('');
|
this.setNoteFilterText('');
|
||||||
this.onFilterEnter();
|
this.onFilterEnter();
|
||||||
|
|||||||
@@ -52,11 +52,11 @@
|
|||||||
|
|
||||||
.filter-section {
|
.filter-section {
|
||||||
clear: left;
|
clear: left;
|
||||||
height: 28px;
|
max-height: 80px;
|
||||||
margin-top: 14px;
|
margin-top: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
background-color: var(--sn-stylekit-contrast-background-color);
|
background-color: var(--sn-stylekit-contrast-background-color);
|
||||||
@@ -71,6 +71,20 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-options {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat( 3, 1fr );
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
font-size: var(--sn-stylekit-font-size-p);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-clear-button {
|
#search-clear-button {
|
||||||
@@ -86,9 +100,9 @@
|
|||||||
line-height: 17px;
|
line-height: 17px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 20%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
right: 36px;
|
right: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition: background-color 0.15s linear;
|
transition: background-color 0.15s linear;
|
||||||
|
|||||||
@@ -436,6 +436,10 @@
|
|||||||
border-color: var(--sn-stylekit-neutral-contrast-color);
|
border-color: var(--sn-stylekit-neutral-contrast-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-secondary {
|
||||||
|
border-color: var(--sn-stylekit-secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.sn-component .border-r-1px {
|
.sn-component .border-r-1px {
|
||||||
border-right-width: 1px;
|
border-right-width: 1px;
|
||||||
}
|
}
|
||||||
@@ -938,3 +942,42 @@
|
|||||||
.invisible {
|
.invisible {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-neutral-contrast {
|
||||||
|
color: var(--sn-stylekit-neutral-contrast-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active\:bg-info:active {
|
||||||
|
background-color: var(--sn-stylekit-info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active\:border-info:active {
|
||||||
|
border-color: var(--sn-stylekit-info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active\:color-neutral-contrast:active {
|
||||||
|
color: var(--sn-stylekit-neutral-contrast-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition {
|
||||||
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||||
|
transition-timing-function: cubic-bezier(.4,0,.2,1);
|
||||||
|
transition-duration: 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-from-top {
|
||||||
|
animation: fade-from-top .2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-from-top {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20%);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user