Filter bar + helper popup for search form

This commit is contained in:
Lim Chee Aun 2023-04-29 20:59:51 +08:00
parent 8f7c6a159b
commit eeb5730932
3 changed files with 433 additions and 85 deletions

View file

@ -19,7 +19,7 @@ const Link = forwardRef((props, ref) => {
let hash = (location.hash || '').replace(/^#/, '').trim();
if (hash === '') hash = '/';
const { to, ...restProps } = props;
const isActive = hash === to;
const isActive = decodeURIComponent(hash) === to;
return (
<a
ref={ref}

View file

@ -43,3 +43,61 @@ ul.link-list.hashtag-list li a {
background-color: var(--bg-color);
}
}
.search-popover-container {
position: relative;
}
.search-popover {
position: absolute;
left: 8px;
max-width: calc(100% - 16px);
/* right: 8px; */
background-color: var(--bg-color);
border: 1px solid var(--outline-color);
box-shadow: 0 4px 24px var(--drop-shadow-color);
border-radius: 8px;
display: flex;
flex-direction: column;
animation: appear-smooth 0.2s ease-out;
overflow: hidden;
}
.search-popover[hidden] {
display: none;
}
.search-popover-item {
text-decoration: none;
padding: 8px 16px 8px 8px;
display: flex;
gap: 8px;
align-items: center;
}
.search-popover-item[hidden] {
display: none;
}
.search-popover-item:is(:hover, :focus, .focus) {
background-color: var(--button-bg-color);
color: var(--button-text-color);
}
.search-popover-item :is(mark, q) {
background-color: var(--bg-faded-blur-color);
color: inherit;
}
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
background-color: var(--button-bg-color);
}
.search-popover:hover .search-popover-item.focus:not(:hover, :focus),
.search-popover:hover
.search-popover-item.focus:not(:hover, :focus)
:is(mark, q) {
background-color: unset;
color: unset;
}
.search-popover-item > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-popover-item:is(:hover, :focus, .focus) > .icon {
opacity: 1;
}

View file

@ -1,6 +1,7 @@
import './search.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import { useEffect, useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import AccountBlock from '../components/account-block';
@ -18,25 +19,44 @@ function Search(props) {
instance: params.instance,
});
const [uiState, setUiState] = useState('default');
const [searchParams, setSearchParams] = useSearchParams();
const searchFieldRef = useRef();
const [searchParams] = useSearchParams();
const searchFormRef = useRef();
const q = props?.query || searchParams.get('q');
useTitle(q ? `Search: ${q}` : 'Search', `/search`);
const type = props?.type || searchParams.get('type');
useTitle(
q
? `Search: ${q}${
type
? ` (${
{
statuses: 'Posts',
accounts: 'Accounts',
hashtags: 'Hashtags',
}[type]
})`
: ''
}`
: 'Search',
`/search`,
);
const [statusResults, setStatusResults] = useState([]);
const [accountResults, setAccountResults] = useState([]);
const [hashtagResults, setHashtagResults] = useState([]);
useEffect(() => {
searchFieldRef.current?.focus?.();
// searchFieldRef.current?.focus?.();
// searchFormRef.current?.focus?.();
if (q) {
searchFieldRef.current.value = q;
// searchFieldRef.current.value = q;
searchFormRef.current?.setValue?.(q);
setUiState('loading');
(async () => {
const results = await masto.v2.search({
q,
limit: 20,
limit: type ? 40 : 5,
resolve: authenticated,
type,
});
console.log(results);
setStatusResults(results.statuses);
@ -45,7 +65,7 @@ function Search(props) {
setUiState('default');
})();
}
}, [q, instance]);
}, [q, type, instance]);
return (
<div id="search-page" class="deck-container">
@ -55,89 +75,153 @@ function Search(props) {
<div class="header-side">
<NavMenu />
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const { q } = e.target;
if (q.value) {
setSearchParams({ q: q.value });
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
name="q"
type="search"
autofocus
placeholder="Search"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
/>
</form>
<div class="header-side" />
<SearchForm ref={searchFormRef} />
<div class="header-side">&nbsp;</div>
</div>
</header>
<main>
{!!q && (
<div class="filter-bar">
{!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}> All</Link>}
{[
{
label: 'Accounts',
type: 'accounts',
to: `/search?q=${q}&type=accounts`,
},
{
label: 'Hashtags',
type: 'hashtags',
to: `/search?q=${q}&type=hashtags`,
},
{
label: 'Posts',
type: 'statuses',
to: `/search?q=${q}&type=statuses`,
},
]
.sort((a, b) => {
if (a.type === type) return -1;
if (b.type === type) return 1;
return 0;
})
.map((link) => (
<Link to={link.to}>{link.label}</Link>
))}
</div>
)}
{!!q && uiState !== 'loading' ? (
<>
<h2 class="timeline-header">Accounts</h2>
{accountResults.length > 0 ? (
<ul class="timeline flat accounts-list">
{accountResults.map((account) => (
<li>
<AccountBlock account={account} instance={instance} />
</li>
))}
</ul>
) : (
<p class="ui-state">No accounts found.</p>
{(!type || type === 'accounts') && (
<>
{type !== 'accounts' && (
<h2 class="timeline-header">Accounts</h2>
)}
{accountResults.length > 0 ? (
<>
<ul class="timeline flat accounts-list">
{accountResults.map((account) => (
<li>
<AccountBlock
account={account}
instance={instance}
/>
</li>
))}
</ul>
{type !== 'accounts' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=accounts`}
>
See more accounts <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No accounts found.</p>
)}
</>
)}
<h2 class="timeline-header">Hashtags</h2>
{hashtagResults.length > 0 ? (
<ul class="link-list hashtag-list">
{hashtagResults.map((hashtag) => (
<li>
<Link
to={
instance
? `/${instance}/t/${hashtag.name}`
: `/t/${hashtag.name}`
}
>
<Icon icon="hashtag" />
<span>{hashtag.name}</span>
</Link>
</li>
))}
</ul>
) : (
<p class="ui-state">No hashtags found.</p>
{(!type || type === 'hashtags') && (
<>
{type !== 'hashtags' && (
<h2 class="timeline-header">Hashtags</h2>
)}
{hashtagResults.length > 0 ? (
<>
<ul class="link-list hashtag-list">
{hashtagResults.map((hashtag) => (
<li>
<Link
to={
instance
? `/${instance}/t/${hashtag.name}`
: `/t/${hashtag.name}`
}
>
<Icon icon="hashtag" />
<span>{hashtag.name}</span>
</Link>
</li>
))}
</ul>
{type !== 'hashtags' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=hashtags`}
>
See more hashtags <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No hashtags found.</p>
)}
</>
)}
<h2 class="timeline-header">Posts</h2>
{statusResults.length > 0 ? (
<ul class="timeline">
{statusResults.map((status) => (
<li>
<Link
class="status-link"
to={
instance
? `/${instance}/s/${status.id}`
: `/s/${status.id}`
}
>
<Status status={status} />
</Link>
</li>
))}
</ul>
) : (
<p class="ui-state">No posts found.</p>
{(!type || type === 'statuses') && (
<>
{type !== 'statuses' && (
<h2 class="timeline-header">Posts</h2>
)}
{statusResults.length > 0 ? (
<>
<ul class="timeline">
{statusResults.map((status) => (
<li>
<Link
class="status-link"
to={
instance
? `/${instance}/s/${status.id}`
: `/s/${status.id}`
}
>
<Status status={status} />
</Link>
</li>
))}
</ul>
{type !== 'statuses' && (
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=statuses`}
>
See more posts <Icon icon="arrow-right" />
</Link>
</div>
)}
</>
) : (
<p class="ui-state">No posts found.</p>
)}
</>
)}
</>
) : uiState === 'loading' ? (
@ -156,3 +240,209 @@ function Search(props) {
}
export default Search;
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.q || '');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
if (query) {
setSearchParams({
q: query,
});
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{!!query &&
[
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
},
]
.sort((a, b) => {
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
<Link to={to} class="search-popover-item" hidden={hidden}>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});