Global search command trigger

pull/228/head
Lim Chee Aun 2023-09-04 14:49:39 +08:00
rodzic 3511ba760a
commit 0fd719d3e7
6 zmienionych plików z 376 dodań i 219 usunięć

Wyświetl plik

@ -26,6 +26,7 @@ import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal';
import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
@ -449,6 +450,7 @@ function App() {
)}
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} />
</>
);
}

Wyświetl plik

@ -0,0 +1,54 @@
#search-command-container {
position: fixed;
inset: 0;
z-index: 1002;
background-color: var(--backdrop-darker-color);
background-image: radial-gradient(
farthest-corner at top,
var(--backdrop-color),
transparent
);
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
transition: opacity 0.1s ease-in-out;
}
#search-command-container[hidden] {
opacity: 0;
pointer-events: none;
}
#search-command-container form {
width: calc(40em - 32px);
max-width: 100%;
transition: transform 0.1s ease-in-out;
}
#search-command-container[hidden] form {
transform: translateY(-64px) scale(0.9);
}
#search-command-container input {
width: 100%;
padding: 16px;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid var(--outline-color);
box-shadow: 0 2px 16px var(--drop-shadow-color),
0 32px 64px var(--drop-shadow-color);
}
#search-command-container input:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
}
@media (min-width: 40em) {
#search-command-container {
align-items: center;
background-image: radial-gradient(
closest-side,
var(--backdrop-color),
transparent
);
}
}

Wyświetl plik

@ -0,0 +1,67 @@
import './search-command.css';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import SearchForm from './search-form';
export default function SearchCommand({ onClose = () => {} }) {
const [showSearch, setShowSearch] = useState(false);
const searchFormRef = useRef(null);
useHotkeys(
'/',
(e) => {
setShowSearch(true);
setTimeout(() => {
searchFormRef.current?.focus?.();
}, 0);
},
{
preventDefault: true,
ignoreEventWhen: (e) => {
const isSearchPage = /\/search/.test(location.hash);
const hasModal = !!document.querySelector('#modal-container > *');
return isSearchPage || hasModal;
},
},
);
const closeSearch = () => {
setShowSearch(false);
onClose();
};
useHotkeys(
'esc',
(e) => {
searchFormRef.current?.blur?.();
closeSearch();
},
{
enabled: showSearch,
enableOnFormTags: true,
preventDefault: true,
},
);
return (
<div
id="search-command-container"
hidden={!showSearch}
onClick={(e) => {
console.log(e);
if (e.target === e.currentTarget) {
closeSearch();
}
}}
>
<SearchForm
ref={searchFormRef}
onSubmit={() => {
closeSearch();
}}
/>
</div>
);
}

Wyświetl plik

@ -0,0 +1,237 @@
import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { api } from '../utils/api';
import Icon from './icon';
import Link from './link';
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
blur: () => {
searchFieldRef.current.blur();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
const isSearchPage = /\/search/.test(location.hash);
if (isSearchPage) {
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
} else {
if (query) {
location.hash = `/search?q=${encodeURIComponent(query)}${
type ? `&type=${type}` : ''
}`;
} else {
location.hash = `/search`;
}
}
props?.onSubmit?.(e);
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
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();
props?.onSubmit?.(e);
}
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>
);
});
export default SearchForm;

Wyświetl plik

@ -52,6 +52,7 @@
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #ccc;
--img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199;

Wyświetl plik

@ -1,13 +1,7 @@
import './search.css';
import { forwardRef } from 'preact/compat';
import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'preact/hooks';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useParams, useSearchParams } from 'react-router-dom';
@ -16,6 +10,7 @@ import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NavMenu from '../components/nav-menu';
import SearchForm from '../components/search-form';
import Status from '../components/status';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
@ -128,10 +123,21 @@ function Search(props) {
if (q) {
searchFormRef.current?.setValue?.(q);
loadResults(true);
} else {
searchFormRef.current?.focus?.();
}
searchFormRef.current?.focus?.();
}, [q, type, instance]);
useHotkeys(
'/',
(e) => {
searchFormRef.current?.focus?.();
},
{
preventDefault: true,
},
);
return (
<div id="search-page" class="deck-container" ref={scrollableRef}>
<div class="timeline-deck deck">
@ -356,213 +362,3 @@ 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.get('q') || '');
const type = searchParams.get('type');
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) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
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>
);
});