diff --git a/src/app.jsx b/src/app.jsx index 1f9ddc01..ed9bd15e 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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() { )} + ); } diff --git a/src/components/search-command.css b/src/components/search-command.css new file mode 100644 index 00000000..3700758b --- /dev/null +++ b/src/components/search-command.css @@ -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 + ); + } +} diff --git a/src/components/search-command.jsx b/src/components/search-command.jsx new file mode 100644 index 00000000..591ab270 --- /dev/null +++ b/src/components/search-command.jsx @@ -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 ( + + ); +} diff --git a/src/components/search-form.jsx b/src/components/search-form.jsx new file mode 100644 index 00000000..9b7e1051 --- /dev/null +++ b/src/components/search-form.jsx @@ -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 ( +
{ + 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); + }} + > + { + 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; + } + }} + /> + +
+ ); +}); + +export default SearchForm; diff --git a/src/index.css b/src/index.css index 2b2eaa16..751ec5f2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/search.jsx b/src/pages/search.jsx index 7775308d..8037724e 100644 --- a/src/pages/search.jsx +++ b/src/pages/search.jsx @@ -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 (
@@ -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 ( -
{ - e.preventDefault(); - - if (query) { - const params = { - q: query, - }; - if (type) params.type = type; // Preserve type - setSearchParams(params); - } else { - setSearchParams({}); - } - }} - > - { - 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; - } - }} - /> - -
- ); -});