Filter bar + helper popup for search form
This commit is contained in:
parent
8f7c6a159b
commit
eeb5730932
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"> </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>
|
||||
);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue