feat: implement search component

This commit is contained in:
Hossein Mehrabi
2023-05-31 21:45:29 +03:30
parent cad1214aa3
commit f6565f321f
30 changed files with 553 additions and 322 deletions
@@ -5,9 +5,11 @@ import React from 'react'
import styles from './style.module.scss'
import ArrowLCircleSvg from '../../static/icons/arrow-left-circle.svg'
import ArrowRCircleSvg from '../../static/icons/arrow-right-circle.svg'
import ArrowL from '../../static/icons/arrow-left.svg'
import ArrowRCircleSvg from '../../static/icons/arrow-right-circle.svg'
import ArrowR from '../../static/icons/arrow-right.svg'
import CloseSvg from '../../static/icons/close.svg'
import CopySvg from '../../static/icons/copy.svg'
import DiscordSvg from '../../static/icons/discord.svg'
import DiscourseSvg from '../../static/icons/discourse.svg'
import DotSvg from '../../static/icons/dot.svg'
@@ -15,12 +17,12 @@ import DropdownSvg from '../../static/icons/dropdown.svg'
import FolderSvg from '../../static/icons/folder.svg'
import GithubSvg from '../../static/icons/github.svg'
import GScholarSvg from '../../static/icons/gscholar.svg'
import HistorySvg from '../../static/icons/history.svg'
import LinkedinSvg from '../../static/icons/linkedin.svg'
import SearchSvg from '../../static/icons/search.svg'
import StatusSvg from '../../static/icons/status.svg'
import TelegramSvg from '../../static/icons/telegram.svg'
import TwitterSvg from '../../static/icons/twitter.svg'
import CopySvg from '../../static/icons/copy.svg'
type TIconProps = {
size?: 's' | 'm' | 'l'
@@ -157,3 +159,15 @@ export const IconCopy = (props: TIconProps): JSX.Element => (
<CopySvg />
</Icon>
)
export const IconHistory = (props: TIconProps): JSX.Element => (
<Icon {...props}>
<HistorySvg />
</Icon>
)
export const IconClose = (props: TIconProps): JSX.Element => (
<Icon {...props}>
<CloseSvg />
</Icon>
)
@@ -0,0 +1,81 @@
@use '../../css/utils';
.l-modal {
opacity: 0;
visibility: hidden;
transition: 0.3s;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: 9999;
}
.l-modal__container {
max-width: 1376px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 1rem;
overflow: auto;
}
.l-modal--open {
opacity: 1;
visibility: visible;
}
.l-modal__content {
z-index: 9998;
grid-column: 8 / 19;
background: rgb(var(--lsd-surface-primary));
border: 1px solid rgb(var(--lsd-border-primary));
padding: 7px;
height: fit-content;
margin: 64px 0;
max-height: 80vh;
}
.l-modal__backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(var(--lsd-surface-primary), 0.6);
z-index: 9997;
}
@include utils.responsive('lg', 'down') {
.l-modal {
width: 100vw;
overflow: hidden;
}
.l-modal__container {
width: 100%;
max-width: unset;
display: grid;
grid-template-columns: 1fr;
height: 100vh;
min-height: -webkit-fill-available;
}
.l-modal__content {
grid-column: 1 / 2;
grid-row: 1 / 2;
margin: 0;
border: none;
height: 100vh;
max-height: 100vh;
overflow: hidden;
height: 100%;
}
.l-modal__backdrop {
display: none;
}
}
@@ -0,0 +1,53 @@
import { useLockBodyScroll } from '@docusaurus/theme-common/internal'
import clsx from 'clsx'
import React from 'react'
import { useKeyPressEvent } from 'react-use'
import { useHydrated } from '../../lib/useHydrated'
import { Portal } from '../Portal/Portal'
import './Modal.scss'
export type ModalProps = React.HTMLAttributes<HTMLDivElement> & {
open?: boolean
onClose?: () => void
keepMounted?: boolean
}
export const Modal: React.FC<ModalProps> = ({
open = false,
onClose,
keepMounted = false,
className,
children,
...props
}) => {
const hydrated = useHydrated()
const handleClose = () => {
onClose && onClose()
}
useLockBodyScroll(open)
useKeyPressEvent(
(key) => key.code === 'Escape',
(event) => {
handleClose()
},
)
if (!hydrated) return <></>
if (!open && !keepMounted) return <></>
return (
<Portal containerId="lsd-presentation" id={props.id}>
<div
className={clsx(className, 'l-modal', open && 'l-modal--open')}
{...(props as any)}
>
<div className="l-modal__container">
<div className="l-modal__content">{children}</div>
<div className="l-modal__backdrop" onClick={handleClose} />
</div>
</div>
</Portal>
)
}
@@ -0,0 +1 @@
export * from './Modal'
@@ -18,5 +18,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
const portalElement = usePortal({ parentId: containerId })
return createPortal(children, portalElement, id)
const portal = createPortal(children, portalElement, id)
return portal
}
@@ -0,0 +1,46 @@
import { useLocalStorage } from 'react-use'
export const usePersistedHistory = <T = any>(
key: string,
options?: {
equals?: (a: T, b: T) => boolean
unique?: boolean
},
) => {
const unique = options?.unique ?? false
const equals = options?.equals ?? ((a, b) => a === b)
const [history, setHistory] = useLocalStorage<T[]>(
'logos-docusaurus-theme-' + key,
[],
)
const add = (value: T) => {
const arr = history ?? []
setHistory([
value,
...(unique ? arr.filter((item) => !equals(item, value)) : arr),
])
}
const remove = (rm: (item: T, index: number) => boolean) => {
const arr = history ?? []
setHistory(arr.filter((item, index) => !rm(item, index)))
}
const removeByIndex = (index: number) => {
remove((item, idx) => idx === index)
}
const clear = () => {
setHistory([])
}
return {
add,
clear,
remove,
removeByIndex,
list: history,
}
}
@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<path d="M12.6673 4.27334L11.7273 3.33334L8.00065 7.06001L4.27398 3.33334L3.33398 4.27334L7.06065 8.00001L3.33398 11.7267L4.27398 12.6667L8.00065 8.94001L11.7273 12.6667L12.6673 11.7267L8.94065 8.00001L12.6673 4.27334Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -1,3 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.52942 18.2352V1.76465H11.1765L16.4706 7.05877V18.2352H3.52942Z" stroke="black" stroke-width="1.2"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33366 1.33331H4.00033C3.26699 1.33331 2.67366 1.93331 2.67366 2.66665L2.66699 13.3333C2.66699 14.0666 3.26033 14.6666 3.99366 14.6666H12.0003C12.7337 14.6666 13.3337 14.0666 13.3337 13.3333V5.33331L9.33366 1.33331ZM4.00033 13.3333V2.66665H8.66699V5.99998H12.0003V13.3333H4.00033Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 14C6.46667 14 5.13044 13.4916 3.99133 12.4747C2.85222 11.4578 2.19956 10.1884 2.03333 8.66667H3.4C3.55556 9.82222 4.06956 10.7778 4.942 11.5333C5.81444 12.2889 6.83378 12.6667 8 12.6667C9.3 12.6667 10.4029 12.2138 11.3087 11.308C12.2144 10.4022 12.6671 9.29956 12.6667 8C12.6667 6.7 12.2138 5.59711 11.308 4.69133C10.4022 3.78556 9.29956 3.33289 8 3.33333C7.23333 3.33333 6.51667 3.51111 5.85 3.86667C5.18333 4.22222 4.62222 4.71111 4.16667 5.33333H6V6.66667H2V2.66667H3.33333V4.23333C3.9 3.52222 4.59178 2.97222 5.40867 2.58333C6.22556 2.19444 7.08933 2 8 2C8.83333 2 9.614 2.15844 10.342 2.47533C11.07 2.79222 11.7033 3.21978 12.242 3.758C12.7807 4.29711 13.2084 4.93044 13.5253 5.658C13.8422 6.38556 14.0004 7.16622 14 8C14 8.83333 13.8416 9.614 13.5247 10.342C13.2078 11.07 12.7802 11.7033 12.242 12.242C11.7029 12.7807 11.0696 13.2084 10.342 13.5253C9.61444 13.8422 8.83378 14.0004 8 14ZM9.86667 10.8L7.33333 8.26667V4.66667H8.66667V7.73333L10.8 9.86667L9.86667 10.8Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,11 +1,32 @@
@use '../../css/utils';
.root {
position: relative;
.modal {
> div > div:first-child {
padding: 0;
}
.closeButton {
display: none;
}
.header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
@include utils.responsive('lg', 'down') {
.root {
position: unset;
.modal {
.header {
padding: 12px 16px;
}
.closeButton {
display: block;
width: 2rem;
height: 2rem;
}
}
}
@@ -1,49 +1,49 @@
import { useLocation } from '@docusaurus/router'
import clsx from 'clsx'
import { CloseIcon, IconButton, SearchIcon } from '@acid-info/lsd-react'
import React, { useEffect, useRef, useState } from 'react'
import { Modal } from '../../components/Modal/Modal'
import { usePersistedHistory } from '../../lib/usePersistedHistory'
import { useWindowEventListener } from '../../lib/useWindowEventListener'
import { useSearch } from './hooks/useSearch'
import styles from './SearchBar.module.scss'
import { SearchInput } from './SearchInput'
import { SearchResults } from './SearchResults'
import { SearchResultsContainer } from './SearchResultsContainer'
import { SearchResult } from './types'
import { SearchHistory } from './SearchHistory/SearchHistory'
import { SearchInput } from './SearchInput/SearchInput'
import { SearchResults } from './SearchResults/SearchResults'
import { SearchResult, SearchResultGroupItem } from './types'
export const SearchBar: React.FC<{}> = ({}) => {
const history = usePersistedHistory<SearchResultGroupItem>('search', {
unique: true,
equals: (a, b) => a.title === b.title && a.href === b.href,
})
const search = useSearch()
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const location = useLocation()
const [input, setInput] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [showResultsContainer, setShowResultsContainer] = useState(false)
const [displayModal, setDisplayModal] = useState(false)
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
const onInputFocus = () => {
setShowResultsContainer(true)
}
const focusOnInput = () => {
const el = ref.current
if (!el) return
const onInputCancel = () => {
setShowResultsContainer(false)
}
const onClickOutsideResultsContainer = (event: Event) => {
if (!event.composedPath().find((el) => el === ref.current)) onInputCancel()
const inputEl = el.querySelector('input')
if (inputEl)
setTimeout(() => {
inputEl.focus()
}, 50)
}
const onClear = () => {
setInput('')
inputRef.current && inputRef.current.focus()
}
const query = async (input: string) => {
const { results } = await search.query(input)
setResults(results)
setShowResultsContainer(true)
}
useEffect(() => {
@@ -52,31 +52,69 @@ export const SearchBar: React.FC<{}> = ({}) => {
}, [input])
useEffect(() => {
if (showResultsContainer) {
setShowResultsContainer(false)
}
}, [location.key])
displayModal ? focusOnInput() : setInput('')
}, [displayModal])
const onNavigate = (
e: React.MouseEvent<HTMLAnchorElement>,
item: SearchResultGroupItem,
) => {
e.preventDefault()
setDisplayModal(false)
history.add(item)
window.location.href = item.href
}
useWindowEventListener(
'keydown',
(event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
event.preventDefault()
setDisplayModal(true)
}
},
{},
[],
)
return (
<div ref={ref} className={clsx(styles.root)}>
<SearchInput
value={input}
active={showResultsContainer}
inputProps={{
ref: inputRef,
placeholder: 'Enter...',
}}
onChange={onInputChange}
onFocus={onInputFocus}
onCancel={onInputCancel}
/>
<SearchResultsContainer
visible={showResultsContainer && input.length > 0}
inputRef={inputRef}
onClickOutside={onClickOutsideResultsContainer}
<>
<IconButton onClick={() => setDisplayModal(true)} size="medium">
<SearchIcon />
</IconButton>
<Modal
keepMounted
id="search-modal"
open={displayModal}
onClose={() => setDisplayModal(false)}
className={styles.modal}
>
<SearchResults results={results} onClear={onClear} />
</SearchResultsContainer>
</div>
<div className={styles.header}>
<SearchInput
containerRef={ref}
onClear={onClear}
value={input}
onChange={onInputChange}
/>
<IconButton
className={styles.closeButton}
size="medium"
onClick={() => setDisplayModal(false)}
>
<CloseIcon color="primary" />
</IconButton>
</div>
{input.length > 0 && (
<SearchResults results={results} onNavigate={onNavigate} />
)}
{input.length === 0 && (
<SearchHistory
history={history.list ?? []}
onRemove={history.removeByIndex}
onClose={() => setDisplayModal(false)}
/>
)}
</Modal>
</>
)
}
@@ -0,0 +1,45 @@
@use '../../../css/utils';
.root {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.root.empty {
padding: 0;
}
.title {
font-size: 0.75rem !important;
}
.item {
margin-top: 1rem;
display: flex;
flex-direction: row;
align-items: center;
.itemTitle {
flex-grow: 1;
padding-left: 1rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
mark {
background: none;
color: inherit;
}
}
}
@include utils.responsive('lg', 'down') {
.root {
overflow: auto;
height: 100%;
max-height: 100%;
padding: 24px 24px;
padding-bottom: 64px;
}
}
@@ -0,0 +1,63 @@
import { Typography } from '@acid-info/lsd-react'
import clsx from 'clsx'
import React from 'react'
import { IconClose, IconHistory } from '../../../components/Icon/Icon'
import { SearchResultMessage } from '../SearchResultMessage/SearchResultMessage'
import { SearchResultGroupItem } from '../types'
import styles from './SearchHistory.module.scss'
export type SearchHistoryProps = React.HTMLProps<HTMLDivElement> & {
history: SearchResultGroupItem[]
onRemove: (index: number) => void
onClose?: () => void
}
export const SearchHistory: React.FC<SearchHistoryProps> = ({
history: list = [],
onRemove,
onClose,
className,
children,
...props
}) => {
return (
<div
className={clsx(
className,
styles.root,
list.length === 0 && styles.empty,
)}
{...props}
>
{list.length === 0 ? (
<SearchResultMessage>No recent searches</SearchResultMessage>
) : (
<>
<Typography
className={styles.title}
variant="subtitle2"
component="div"
>
Recent
</Typography>
{list.map((item, index) => (
<div key={index} className={styles.item}>
<IconHistory />
<Typography
variant="subtitle2"
component="a"
href={item.href}
className={styles.itemTitle}
dangerouslySetInnerHTML={{ __html: item.title }}
onClick={() => onClose && onClose()}
/>
<button className="clean-btn" onClick={() => onRemove(index)}>
<IconClose />
</button>
</div>
))}
</>
)}
</div>
)
}
@@ -0,0 +1 @@
export * from './SearchHistory'
@@ -5,96 +5,30 @@
.root {
width: auto;
position: relative;
width: 100%;
padding: 8px;
padding-bottom: 0;
box-sizing: border-box;
}
.textField {
width: 100% !important;
}
.root input {
width: 134px;
border: 0;
border-radius: 8px;
background: rgb(var(--lsd-surface-primary));
padding: 9px 48px 9px 16px;
transition: 0.2s;
&:focus {
outline: none;
}
}
.root.expanded input {
background: rgb(var(--lsd-surface-primary));
width: 334px;
}
.root.expanded .label {
display: none;
}
.label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #747475;
font-size: 0.875rem;
pointer-events: none;
}
.shortcuts {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
line-height: 0;
pointer-events: none;
& > kbd {
margin-left: 0.375rem;
}
kbd {
background: none;
border: none;
padding: 0;
color: #bababa;
box-shadow: none;
font-size: 0.75rem;
}
}
[data-theme='dark'] {
.root input {
color: rgb(var(--lsd-text-primary));
}
.root.expanded input {
background-color: rgb(var(--lsd-surface-primary));
}
}
@include utils.responsive('lg', 'down') {
.shortcuts {
display: none;
}
.root {
width: 100%;
}
.root input {
width: 100%;
border-radius: 12px;
background: #eeeef0;
}
.root.expanded input {
width: 100%;
}
.label {
display: none;
padding: 0;
}
}
@@ -1,88 +1,65 @@
import { useOS } from '@logos-theme/lib/useOS'
import { useWindowEventListener } from '@logos-theme/lib/useWindowEventListener'
import { SearchIcon, TextField } from '@acid-info/lsd-react'
import clsx from 'clsx'
import React from 'react'
import { useMedia } from 'react-use'
import styles from './SearchInput.module.scss'
import { Typography } from '@acid-info/lsd-react'
export type SearchInputProps = Omit<
React.HTMLProps<HTMLDivElement>,
'value' | 'onChange'
> &
Pick<React.HTMLProps<HTMLInputElement>, 'onChange'> & {
inputProps?: Omit<React.HTMLProps<HTMLInputElement>, 'ref'> & {
ref: React.RefObject<HTMLInputElement>
}
inputProps?: React.HTMLProps<HTMLInputElement>
containerRef?: React.RefObject<HTMLDivElement>
value?: string
active?: boolean
onFocus?: () => void
onCancel?: () => void
onClear?: () => void
}
export const SearchInput: React.FC<SearchInputProps> = ({
value = '',
active,
onChange,
onClear,
onFocus: onFocusCallback,
onCancel,
className,
inputProps: { ref: inputRef, ...inputProps } = { placeholder: '' },
containerRef,
...props
}) => {
const os = useOS()
const isMobile = useMedia('(max-width: 996px)')
const expanded = active || value?.length > 0
const focus = () => {
inputRef?.current && inputRef.current.focus()
}
const blur = () => {
inputRef?.current && inputRef.current.blur()
onCancel && onCancel()
}
useWindowEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
event.preventDefault()
focus()
} else if (event.code === 'Escape') {
blur()
}
})
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
onFocusCallback && onFocusCallback()
}
return (
<div
ref={containerRef}
className={clsx(styles.root, expanded && styles.expanded, className)}
onKeyDown={(e) => {
if (e.code === 'Escape' && value.length > 0) {
e.stopPropagation()
onClear && onClear()
}
}}
{...props}
>
<Typography component="span" color="primary">
Search
</Typography>
<input
<TextField
className={styles.textField}
value={value}
onChange={onChange}
ref={inputRef}
onFocus={onFocus}
{...inputProps}
placeholder={expanded || isMobile ? inputProps.placeholder : ''}
onChange={onChange}
onFocus={onFocus}
clearButton
icon={<SearchIcon color="primary" />}
{...(inputProps as any)}
/>
<div className={styles.shortcuts}>
{active ? (
<kbd>esc</kbd>
) : (
<>
<kbd>{os === 'mac' ? '⌘' : 'ctrl'}</kbd>
<kbd>k</kbd>
</>
)}
</div>
</div>
)
}
@@ -1,17 +1,20 @@
.root {
& > div:first-child {
padding: 0 14px;
font-size: 0.75rem;
font-weight: bold;
padding: 0 24px;
color: #828285;
&,
* {
font-size: 0.75rem;
}
}
ul {
margin-top: 16px;
padding: 0;
}
ul li {
list-style: none;
margin-top: 1rem;
}
}
@@ -1,3 +1,4 @@
import { Typography } from '@acid-info/lsd-react'
import clsx from 'clsx'
import React from 'react'
import styles from './SearchResultGroup.module.scss'
@@ -15,7 +16,7 @@ export const SearchResultGroup: React.FC<SearchResultGroupProps> = ({
return (
<div className={clsx(styles.root, className)} {...props}>
<div>
<span>{title}</span>
<Typography variant="subtitle2">{title}</Typography>
</div>
<ul>{children}</ul>
</div>
@@ -3,11 +3,13 @@
}
.icon {
width: 16px;
height: auto;
&.l1 {
}
&.l2 {
opacity: 0.6;
}
&.fill {
@@ -3,26 +3,21 @@
.root {
display: flex;
align-items: center;
padding: 12px;
padding: 0 24px;
> span {
svg {
vertical-align: middle;
}
}
mark {
color: #3165b4;
color: inherit;
background: none;
}
& > div {
margin-left: 26px;
& > div {
color: #373738;
font-size: 0.875rem;
}
& > p {
color: #828285;
font-size: 0.625rem;
margin: 0;
}
margin-left: 1rem;
}
}

Some files were not shown because too many files have changed in this diff Show More