Skip to content

Commit

Permalink
Merge pull request #24 from Bandmators/feature/search
Browse files Browse the repository at this point in the history
feat(common): search (#23)
  • Loading branch information
kyechan99 authored May 15, 2024
2 parents f4d045c + 1944924 commit b7618ef
Show file tree
Hide file tree
Showing 12 changed files with 508 additions and 29 deletions.
39 changes: 21 additions & 18 deletions src/components/Portal/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,32 @@ import { composeEventHandlers } from '@/libs/event';
import { composeRefs } from '@/libs/ref';
import { PositionType } from '@/types/position';

interface ModalContentProps extends React.ComponentProps<'div'> {
export interface PortalProps extends React.ComponentProps<'div'> {
ref: React.ForwardedRef<HTMLDivElement>;
width?: React.CSSProperties['width'];
disabledBG?: boolean;
disabledAutoFocus?: boolean;
}

const Portal = ({ children, ref, width, onKeyDown, ...props }: ModalContentProps) => {
const Portal = ({ children, ref, width, onKeyDown, disabledAutoFocus, ...props }: PortalProps) => {
const portalRef = React.useRef<HTMLDivElement>(null);
const { showModal, toggleElement, reorgPos } = usePortal({ portalRef });
const { showModal, setShowModal, toggleElement, reorgPos } = usePortal({ portalRef });
const [items, setItems] = useState<HTMLElement[]>([]);

const ACTIONS: Record<string, (e: React.KeyboardEvent<HTMLInputElement>) => void> = {
ArrowDown: () => focus('next'),
ArrowUp: () => focus('prev'),
Tab: e => e.preventDefault(),
Tab: () => focus('next'),
Enter: () => select(),
Escape: () => setShowModal(false),
};

const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (Object.keys(ACTIONS).includes(e.key)) {
const handler = ACTIONS[e.key];
const handler = ACTIONS[e.key];

if (handler) {
e.preventDefault();
handler(e);
}
if (handler) {
e.preventDefault();
handler(e);
}
};

Expand Down Expand Up @@ -65,18 +65,20 @@ const Portal = ({ children, ref, width, onKeyDown, ...props }: ModalContentProps

const handleOpen = React.useCallback(() => {
if (portalRef.current) {
portalRef.current.focus();
setItems(
Array.from(portalRef.current.querySelectorAll<HTMLElement>('[data-focus-enabled="true"]')).filter(e => {
return e.getAttribute('disabled') == null;
}),
);
if (!disabledAutoFocus) portalRef.current.focus();

const newItems = Array.from(
portalRef.current.querySelectorAll<HTMLElement>('[data-focus-enabled="true"]'),
).filter(e => {
return e.getAttribute('disabled') == null;
});
setItems(newItems);
}
}, []);
}, [disabledAutoFocus]);

React.useEffect(() => {
if (showModal && portalRef.current) handleOpen();
}, [handleOpen, showModal]);
}, [handleOpen, showModal, children]);

return (
<PortalStyled
Expand All @@ -102,6 +104,7 @@ const PortalStyled = styled.div<{ width?: React.CSSProperties['width']; position
display: grid;
min-width: max-content;
${({ width }) => width && `width: ${typeof width === 'string' ? width : `${width}px`};`}
max-width: 100%;
padding: 0.25rem;
border-radius: 0.25rem;
position: fixed;
Expand Down
28 changes: 18 additions & 10 deletions src/components/Portal/PortalContent.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import styled from '@emotion/styled';
import React from 'react';
import { createPortal } from 'react-dom';

import useContext from '@/hooks/useContext';

import Portal from './Portal';
import Portal, { PortalProps } from './Portal';
import { PortalContext } from './PortalContext';

interface ModalContentProps extends React.ComponentProps<'div'> {
ref: React.ForwardedRef<HTMLDivElement>;
width?: React.CSSProperties['width'];
disabledBG?: boolean;
}

export const PortalContent = ({ children, ref, width, disabledBG = false, onKeyDown, ...props }: ModalContentProps) => {
export const PortalContent = ({
children,
ref,
width,
disabledBG = false,
disabledAutoFocus = false,
onKeyDown,
...props
}: PortalProps) => {
const { showModal, setShowModal } = useContext(PortalContext)!;

const close = () => {
Expand All @@ -26,7 +27,14 @@ export const PortalContent = ({ children, ref, width, disabledBG = false, onKeyD
createPortal(
<>
{!disabledBG && <PortalBG id="bmates-portal-bg" className="bmates-portal-bg" onClick={close} />}
<Portal ref={ref} width={width} onKeyDown={onKeyDown} {...props}>
<Portal
id="bmates-portal"
ref={ref}
width={width}
onKeyDown={onKeyDown}
disabledAutoFocus={disabledAutoFocus}
{...props}
>
{children}
</Portal>
</>,
Expand Down
19 changes: 19 additions & 0 deletions src/components/Search/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PortalProvider } from '@/components/Portal/PortalProvider';
import { AlignType } from '@/types/align';

interface SearchProps extends React.PropsWithChildren {
align?: AlignType;
space?: number;
}

/**
* Displays a list of menus.
* @returns
*/
export const Search = ({ align = 'center', space = 0, children }: SearchProps) => {
return (
<PortalProvider align={align} space={space}>
{children}
</PortalProvider>
);
};
20 changes: 20 additions & 0 deletions src/components/Search/SearchContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';

import { PortalContent } from '@/components/Portal/PortalContent';

interface ModalProps extends React.ComponentPropsWithoutRef<'div'> {
width?: React.CSSProperties['width'];
}

/**
* SearchContent
* @returns
*/
export const SearchContent = React.forwardRef<HTMLDivElement, ModalProps>(({ width, children, ...props }, ref) => {
return (
<PortalContent width={width} ref={ref} role="group" disabledAutoFocus {...props}>
{children}
</PortalContent>
);
});
SearchContent.displayName = 'SearchContent';
27 changes: 27 additions & 0 deletions src/components/Search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import styled from '@emotion/styled';
import * as React from 'react';

import { Input } from '../Input';

interface InputProps extends React.ComponentPropsWithoutRef<'input'> {}

export const SearchInput = ({ ...props }: InputProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);

React.useEffect(() => {
if (inputRef.current) inputRef.current.focus();
}, []);

return <StyledInput ref={inputRef} {...props} />;
};

const StyledInput = styled(Input)`
margin: 0.375rem 0.375rem;
padding: 0.375rem 0.375rem;
width: auto;
border-radius: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
outline: 2px solid transparent;
outline-offset: 2px;
`;
70 changes: 70 additions & 0 deletions src/components/Search/SearchInputToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as React from 'react';

import { Input } from '@/components/Input';
import { PortalContext } from '@/components/Portal/PortalContext';
import useContext from '@/hooks/useContext';
import { composeEventHandlers } from '@/libs/event';
import { composeRefs } from '@/libs/ref';

type ComponentPropsWithoutRef<E extends React.ElementType> = React.ComponentPropsWithoutRef<E>;

/**
* SearchInputToggle
* @returns
*/
export const SearchInputToggle = React.forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
({ onClick, onChange, onKeyDown, ...props }, ref) => {
const { showModal, setShowModal, setToggleElment } = useContext(PortalContext);
const compRef = React.useRef<HTMLInputElement | null>(null);

const openModal = () => {
if (showModal) return;

if (compRef.current) {
const rect = compRef.current;
setToggleElment(rect);
}

setShowModal(true);
};

const closeModal = React.useCallback(() => {
setShowModal(false);
}, []);

const ACTIONS: Record<string, (e: React.KeyboardEvent<HTMLInputElement>) => void> = {
ArrowDown: () => focus('next'),
ArrowUp: () => focus('prev'),
Tab: () => closeModal(),
Enter: () => focus('next'),
Escape: () => closeModal(),
};

const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
console.log(e);
const handler = ACTIONS[e.key];

if (handler) {
// e.preventDefault();
handler(e);
}
};

const focus = (key: 'next' | 'prev'): void => {
const el = document.querySelector('#bmates-portal [data-focus-enabled="true"]') as HTMLElement;
if (key === 'next' && el) el.focus();
};

return (
<Input
ref={composeRefs(compRef, ref)}
aria-haspopup="true"
onClick={composeEventHandlers(openModal, onClick)}
onChange={composeEventHandlers(openModal, onChange)}
onKeyDown={composeEventHandlers(onKeyDown, handleOnKeyDown)}
{...props}
/>
);
},
);
SearchInputToggle.displayName = 'SearchInputToggle';
55 changes: 55 additions & 0 deletions src/components/Search/SearchItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import * as React from 'react';

import { PortalContext } from '@/components/Portal/PortalContext';
import useContext from '@/hooks/useContext';
import { composeEventHandlers } from '@/libs/event';

interface SearchItemProps extends React.ComponentPropsWithoutRef<'li'> {
disabled?: boolean;
}
export const SearchItem = React.forwardRef<HTMLLIElement, SearchItemProps>(({ disabled = false, ...props }, ref) => {
const { setShowModal } = useContext(PortalContext);

const onClickHandler = () => {
if (!disabled) setShowModal(false);
};

return (
<SearchItemStyled
ref={ref}
tabIndex={0}
role={'menuitem'}
disabled={disabled}
onClick={composeEventHandlers(props.onClick, onClickHandler)}
data-focus-enabled="true"
{...props}
></SearchItemStyled>
);
});
SearchItem.displayName = 'SearchItem';

const SearchItemStyled = styled.li<{ disabled: boolean }>`
display: flex;
position: relative;
align-items: center;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
outline: 2px solid transparent;
outline-offset: 2px;
cursor: default;
${({ disabled }) =>
disabled
? css`
opacity: 0.5;
`
: css`
&:hover,
&:focus {
background-color: var(--gray-100);
}
`}
`;
43 changes: 43 additions & 0 deletions src/components/Search/SearchToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from 'react';

import { PortalContext } from '@/components/Portal/PortalContext';
import Slot from '@/components/Slot';
import useContext from '@/hooks/useContext';
import { composeEventHandlers } from '@/libs/event';
import { composeRefs } from '@/libs/ref';

import { Button } from '../..';

type ComponentPropsWithoutRef<E extends React.ElementType> = React.ComponentPropsWithoutRef<E> & {
asChild?: boolean;
};

/**
* SearchToggle
* @returns
*/
export const SearchToggle = React.forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<'button'>>(
({ asChild, onClick, ...props }, ref) => {
const { setShowModal, setToggleElment } = useContext(PortalContext);
const compRef = React.useRef<HTMLButtonElement | null>(null);

const Comp = asChild ? Slot : Button;

return (
<Comp
ref={composeRefs(compRef, ref)}
aria-haspopup="true"
onClick={composeEventHandlers(onClick, () => {
if (compRef.current) {
const rect = compRef.current;
setToggleElment(rect);
}

setShowModal(true);
})}
{...props}
/>
);
},
);
SearchToggle.displayName = 'SearchToggle';
Loading

0 comments on commit b7618ef

Please sign in to comment.