mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-02 06:06:41 +01:00
Global search command trigger
This commit is contained in:
parent
3511ba760a
commit
0fd719d3e7
6 changed files with 376 additions and 219 deletions
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
54
src/components/search-command.css
Normal file
54
src/components/search-command.css
Normal file
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
67
src/components/search-command.jsx
Normal file
67
src/components/search-command.jsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
237
src/components/search-form.jsx
Normal file
237
src/components/search-form.jsx
Normal file
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue