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,
|
||||
onFilterEnter,
|
||||
handleFilterTextChanged,
|
||||
onSearchInputBlur,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
} = appState.notesView;
|
||||
|
||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
|
||||
const [focusedSearch, setFocusedSearch] = useState(false);
|
||||
|
||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
||||
displayOptionsMenuRef,
|
||||
@@ -130,6 +130,9 @@ export const NotesView: FunctionComponent<Props> = observer(
|
||||
setNoteFilterText((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const onSearchFocused = () => setFocusedSearch(true);
|
||||
const onSearchBlurred = () => setFocusedSearch(false);
|
||||
|
||||
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Enter) {
|
||||
onFilterEnter();
|
||||
@@ -179,32 +182,38 @@ export const NotesView: FunctionComponent<Props> = observer(
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-section" role="search">
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onBlur={() => onSearchInputBlur()}
|
||||
/>
|
||||
{noteFilterText ? (
|
||||
<button
|
||||
onClick={clearFilterText}
|
||||
aria-role="button"
|
||||
id="search-clear-button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
<div className="ml-2">
|
||||
<SearchOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onFocus={onSearchFocused}
|
||||
onBlur={onSearchBlurred}
|
||||
/>
|
||||
{noteFilterText && (
|
||||
<button
|
||||
onClick={clearFilterText}
|
||||
aria-role="button"
|
||||
id="search-clear-button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(focusedSearch || noteFilterText) && (
|
||||
<div className="animate-fade-from-top">
|
||||
<SearchOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NoAccountWarning appState={appState} />
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
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 VisuallyHidden from '@reach/visually-hidden';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { Switch } from './Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import Bubble from './Bubble';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
@@ -23,94 +14,33 @@ export const SearchOptions = observer(({ appState }: Props) => {
|
||||
const { includeProtectedContents, includeArchived, includeTrashed } =
|
||||
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() {
|
||||
setLockCloseOnBlur(true);
|
||||
try {
|
||||
await searchOptions.toggleIncludeProtectedContents();
|
||||
} finally {
|
||||
setLockCloseOnBlur(false);
|
||||
}
|
||||
await searchOptions.toggleIncludeProtectedContents();
|
||||
}
|
||||
|
||||
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 (
|
||||
<Disclosure
|
||||
open={open}
|
||||
onChange={() => {
|
||||
updateWidthAndPosition();
|
||||
setOpen(!open);
|
||||
}}
|
||||
<div
|
||||
role="tablist"
|
||||
className="search-options justify-center"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DisclosureButton
|
||||
ref={buttonRef}
|
||||
onBlur={closeOnBlur}
|
||||
className="border-0 p-0 bg-transparent cursor-pointer color-neutral hover:color-info"
|
||||
>
|
||||
<VisuallyHidden>Search options</VisuallyHidden>
|
||||
<Icon type="tune" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxWidth,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated w-80 fixed grid gap-2 py-2"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Switch
|
||||
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>
|
||||
<Bubble
|
||||
label="Protected Contents"
|
||||
selected={includeProtectedContents}
|
||||
onSelect={toggleIncludeProtectedContents}
|
||||
/>
|
||||
|
||||
<Bubble
|
||||
label="Archived"
|
||||
selected={includeArchived}
|
||||
onSelect={searchOptions.toggleIncludeArchived}
|
||||
/>
|
||||
|
||||
<Bubble
|
||||
label="Trashed"
|
||||
selected={includeTrashed}
|
||||
onSelect={searchOptions.toggleIncludeTrashed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -559,10 +559,6 @@ export class NotesViewState {
|
||||
this.reloadNotes();
|
||||
};
|
||||
|
||||
onSearchInputBlur = () => {
|
||||
this.appState.searchOptions.refreshIncludeProtectedContents();
|
||||
};
|
||||
|
||||
clearFilterText = () => {
|
||||
this.setNoteFilterText('');
|
||||
this.onFilterEnter();
|
||||
|
||||
@@ -52,11 +52,11 @@
|
||||
|
||||
.filter-section {
|
||||
clear: left;
|
||||
height: 28px;
|
||||
margin-top: 14px;
|
||||
max-height: 80px;
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.filter-bar {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
@@ -71,6 +71,20 @@
|
||||
border-color: transparent;
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -86,9 +100,9 @@
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
top: 20%;
|
||||
transform: translateY(-50%);
|
||||
right: 36px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color 0.15s linear;
|
||||
|
||||
@@ -436,6 +436,10 @@
|
||||
border-color: var(--sn-stylekit-neutral-contrast-color);
|
||||
}
|
||||
|
||||
.border-secondary {
|
||||
border-color: var(--sn-stylekit-secondary-border-color);
|
||||
}
|
||||
|
||||
.sn-component .border-r-1px {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
@@ -938,3 +942,42 @@
|
||||
.invisible {
|
||||
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